Go Lab

Experiments running real Go concurrency on the server. Live cards execute when you press the button. Open Learn on each card to see what the runtime is doing — and how that compares to Java.

Goroutine Lab

live

Run the same batch twice — sequentially and with one goroutine each — as I/O-bound waits or as real CPU work, and compare the wall time.

  • goroutines
  • sync.WaitGroup
Workload
Learn Goroutines
What is it?

A goroutine is a function the Go runtime can run concurrently with others. You start one with the go keyword. It is not an operating-system thread.

The runtime multiplexes many goroutines (G) onto a smaller set of OS threads (M) using logical processors (P). GOMAXPROCS is how many Ps the process may use — usually the number of logical CPUs.

  • G — goroutine: the unit of work you spawn.
  • P — processor: a scheduler context. There are GOMAXPROCS of them.
  • M — machine: an OS thread that actually runs a G while holding a P.
Why does it exist?

Sequential I/O wastes wall time: one call waits, everything behind it sits idle. Concurrent goroutines let those waits overlap. When the work is CPU, more than one P can run at once — that is parallelism, and it is a different ceiling.

How does Go implement it?

go starts a goroutine. It does not wait. If you need the results, you wait explicitly — this Lab uses sync.WaitGroup.

go process()

var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
    go func() {
        defer wg.Done()
        work()
    }()
}
wg.Wait()
What does our Lab demonstrate?

We run the same N tasks twice: once in a loop, then once with a goroutine per task and a WaitGroup. I/O mode parks each task with time.Sleep. CPU mode burns a FNV-1a loop. The JSON reports sequentialMs, concurrentMs, speedup, NumCPU and GOMAXPROCS.

GoroutinesObserved is runtime.NumGoroutine() sampled right after launch — a snapshot, not a guaranteed peak. Speedup is measured on this request; it is not a constant baked into the page.

Real-world use case

A checkout handler that must call fraud, inventory and pricing independently. Each HTTP client call is I/O. Launch one goroutine per call, wait, then assemble the response. The wall time is close to the slowest dependency, not the sum.

Go vs Java

Start work

Go

go f()

Java

thread.start() or executor.submit(task)

Platform Thread

Go

Not 1:1 with a goroutine. An M is the OS thread.

Java

java.lang.Thread mapped to an OS thread. Heavier stack, 1:1 scheduling.

Virtual Thread (21+)

Go

Closer analogue: both are user-mode, M:N scheduled.

Java

Thread.startVirtualThread / Executors.newVirtualThreadPerTaskExecutor().

Wait for a group

Go

sync.WaitGroup, or errgroup

Java

ExecutorService.invokeAll, CompletableFuture.allOf, StructuredTaskScope

Do not treat a goroutine as a Java Platform Thread. A Platform Thread is an OS thread. A Virtual Thread is the fair modern comparison for I/O-bound fan-out: both park cheaply while blocked.

They are still different models. Go has channels and select in the language. Java coordinates with executors, locks, queues and StructuredTaskScope. Virtual Threads can pin a carrier on some synchronized native calls; Go parks on syscalls through its own scheduler. Neither fact makes one language 'better'.

Other languages

JavaScript async/await is a single-threaded event loop — concurrency without parallel CPU in one isolate (Workers are a separate heap). Python asyncio is cooperative too, and the GIL still serializes CPU threads. C# Task usually runs on the thread pool; it is not a goroutine. Rust has OS threads and async tasks, not green threads in the standard library.

Common mistakes
  • Spawning one goroutine per request fan-out with no cap — that is what the Worker Pool Lab is for.
  • Starting work and never waiting or canceling it. The goroutine outlives the request.
  • Sharing a slice, map or pointer across goroutines without a mutex or a channel.
  • Assuming more goroutines means more speed on CPU-bound work. Parallelism stops at GOMAXPROCS.
When should I not use it?

Skip the extra goroutine when the work is already short and sequential — the scheduling cost is the whole story. Do not use goroutines as a substitute for a profiler: measure I/O overlap and CPU scaling separately, which is why this card has two modes.

Learn I/O-bound vs CPU-bound
What is it?

I/O-bound work spends most of its time waiting: the CPU is idle. Database queries, HTTP, filesystem and network calls look like this.

CPU-bound work spends its time computing: compression, image processing, cryptography, encoding, simulation. The core is busy until the scheduler preempts it.

Why does it exist?

The same go keyword does not buy the same speedup. Two hundred goroutines waiting on HTTP can overlap almost completely. Two hundred goroutines hashing compete for GOMAXPROCS logical processors.

How does Go implement it?

When a goroutine blocks on I/O, the runtime can park that G and run another G on the same M. When a goroutine burns CPU, it occupies a P until it yields. GOMAXPROCS is the parallelism knob, not the concurrency knob.

// I/O stand-in: the goroutine parks.
time.Sleep(wait)

// CPU stand-in: the goroutine computes.
for i := 0; i < n; i++ {
    hash = fnv(hash, data)
}
What does our Lab demonstrate?

This card is one experiment with two modes. I/O uses time.Sleep per task. CPU uses a FNV-1a loop whose 'work' is iteration count, not milliseconds — wall time depends on the machine, so the page never hardcodes a speedup.

On I/O, concurrent wall time can approach the per-task wait, so speedup can approach the task count. On CPU, speedup tends toward NumCPU / GOMAXPROCS, not toward the task count. The response includes both numbers so you can see the ceiling on this run.

Real-world use case

Fetching twenty partner HTTP APIs in one request is I/O-bound: more goroutines help until you hit rate limits or sockets. Resizing twenty product photos in the same request is CPU-bound: more goroutines past the core count mostly add contention. Same fan-out shape, different limit.

Go vs Java

Blocked I/O

Go

G parks; M can run another G.

Java

Platform Thread stays occupied. Virtual Thread unmounts the carrier while blocked on most JDK I/O.

Heavy compute

Go

Limited by GOMAXPROCS.

Java

Limited by cores. A huge Platform or Virtual Thread flood still serializes on the CPU.

Pool choice

Go

Goroutines are cheap; you still bound CPU work.

Java

FixedThreadPool for compute. newVirtualThreadPerTaskExecutor() is for I/O, not a substitute for a compute bound.

Java 21 Virtual Threads removed the old '200 blocking calls need 200 Platform Threads' tax. They did not remove physics: 200 hash loops still share the same cores. That is the same lesson GOMAXPROCS teaches here.

Other languages

Node.js overlaps I/O well and moves CPU to worker threads. CPython asyncio overlaps I/O; CPU needs processes or a C extension. The names differ; the split does not.

Common mistakes
  • Reading a big I/O speedup and assuming CPU mode will match.
  • Spawning hundreds of CPU goroutines 'because goroutines are cheap'.
  • Using Sleep as a model of compute — it models wait, which is why this Lab has a real hash loop.
When should I not use it?

Do not add concurrency to a CPU path that already saturates the cores. Do not treat every handler as I/O-bound because it 'calls a database' if the hot path is serialization or image work in-process.

Channel Lab

live

One producer, one channel, one consumer. Watch send wait grow when the consumer is slow, and how a buffer lets the producer get ahead.

  • chan
  • close
  • range
Channel
Learn Channels
What is it?

A channel is a typed conduit between goroutines. An unbuffered send blocks until another goroutine receives the value — a rendezvous. A buffered send blocks only when the buffer is full.

Channels move values and synchronize the two sides. They do not replace a mutex for every shared map or counter.

Producer
    │
    ▼
 Channel
    │
    ▼
Consumer
Why does it exist?

Shared memory plus locks works, but the easy bug is forgetting who mutates what. A channel makes the hand-off explicit: one goroutine produces, another consumes, and the block is the synchronization.

How does Go implement it?

make(chan T) is unbuffered. make(chan T, n) has n slots. The sender that owns the values closes the channel. The receiver drains with range, which ends when the channel is closed and empty.

ch := make(chan Job)      // unbuffered
buf := make(chan Job, n)  // buffered

ch <- job   // send — may block
job = <-ch  // receive — may block

close(ch)
for job := range ch {
    handle(job)
}
What does our Lab demonstrate?

One producer, one consumer, one channel. The producer records send_attempt, then the send, then send_done. sendWaitMs is the sum of those gaps — we do not ask the runtime whether a goroutine is 'blocked'; we timestamp the call.

Defaults are a fast producer and a slow consumer, so unbuffered send wait is visible. A buffer lets the producer get ahead until the slots fill; after that a send waits the same way. The producer closes; the consumer ranges. ClosedByProducer is part of the result.

Real-world use case

A file ingest pipeline: one goroutine parses records and sends them, another writes batches to storage. The channel is the queue between parse and write. If write is slower, the parser blocks instead of filling RAM.

Go vs Java

Unbuffered chan T

Go

Rendezvous. Send waits for receive.

Java

Closest: SynchronousQueue.transfer. Not a LinkedBlockingQueue.

Buffered chan T

Go

Bounded buffer, then the send waits.

Java

ArrayBlockingQueue / LinkedBlockingQueue with a capacity. put waits; offer can fail.

close + range

Go

Language-level end of stream.

Java

No close. Poison pill, CompletableFuture completion, or a done flag.

BlockingQueue<T> is a collection with blocking methods. A Go channel is also a synchronization primitive with a single closer and a typed direction (chan / <-chan / chan<-). Treating them as drop-in equivalents hides the close/ownership rules.

Other languages

Rust std::sync::mpsc and crossbeam channels, C# System.Threading.Channels, Python queue.Queue — all are queues with blocking. Go is unusual in baking select across several channels into the language.

Common mistakes
  • Closing the channel from the receiver. Only the sender side should close.
  • Sending on a closed channel — that panics.
  • Two goroutines waiting on each other: classic deadlock.
  • Using a channel to protect a counter that a mutex would express in three lines.
When should I not use it?

If two goroutines share a map and both read and write it, a mutex (or a single owner goroutine) is usually clearer than a web of channels. Do not put a channel in every function signature 'because this is Go'.

Worker Pool Lab

live

A fixed set of workers drain a jobs channel and report on a results channel. Compare 1 vs 5 vs 10 workers on the same batch — more workers raise throughput while there is work to share.

  • worker pools
  • chan
  • WaitGroup
Learn Worker Pool
What is it?

A worker pool is a fixed set of goroutines that pull jobs from a channel and push results to another. One hundred jobs does not start one hundred goroutines. The workers are reused.

The question is not how many goroutines I can create, but how much concurrency I should allow.

Jobs
  │
  ▼
Channel
  │
  ├── Worker 1
  ├── Worker 2
  └── Worker 3
  │
  ▼
Results
Why does it exist?

Downstream systems have a width: database connection pools, HTTP rate limits, disk, memory. Unbounded fan-out turns your process into a denial-of-service against itself or against a partner API.

How does Go implement it?

This Lab: unbuffered jobs channel, results channel buffered to the job count, one producer, N workers, one closer that WaitGroup.Waits and then closes results. Workers only receive jobs and only send results — they never close either channel.

jobs := make(chan Job)
results := make(chan Result, n)

for i := 0; i < workers; i++ {
    go worker(jobs, results)
}

// producer sends, then:
close(jobs)
What does our Lab demonstrate?

You pick jobs, workers and workMs. The same RunWorkerPool powers the REST button and Run realtime. Workers start, the producer timestamps each send, workers report started/completed. ByWorker is the observed share — the scheduler assigns work; we do not force a round-robin.

REST returns the full PoolRun when the batch ends. Realtime streams the same events over the WebSocket. Experiment logic is unchanged either way.

Real-world use case

Ten thousand user-uploaded images, at most ten encodes at once — because ffmpeg, RAM and the object store will not take ten thousand concurrent processes. Same pattern for pushing orders into an ERP with a 20-connection pool, or calling a partner API with a documented rate limit.

Go vs Java

Fixed width

Go

N worker goroutines + jobs channel

Java

Executors.newFixedThreadPool(n) — the closest analogue.

Virtual Thread per task

Go

That would be one goroutine per job — this Lab does not do that.

Java

newVirtualThreadPerTaskExecutor() is unbounded concurrency, not a pool.

Bound VTs

Go

The pool is the bound.

Java

Semaphore + virtual threads, or a fixed Platform pool for CPU.

ExecutorService is a family of pools, not one thing. FixedThreadPool matches this Lab. CachedThreadPool and virtual-thread-per-task do not — they grow with the backlog. A bounded queue plus a rejection policy is how Java expresses the same 'how much concurrency' question.

Other languages

Node needs an explicit concurrency limiter (p-limit, a queue). Python asyncio.Semaphore is the usual bound. C# TPL has a default thread pool; you still cap work with ActionBlock or a Channel + N consumers.

Common mistakes
  • More workers than the bottleneck (20 workers, 5 DB connections).
  • An unbounded jobs queue — the pool looks 'safe' while RAM grows.
  • One goroutine per job and calling it a pool.
  • Ignoring backpressure: the producer must have somewhere to wait or reject.
When should I not use it?

A pool of one is a sequential loop with extra machinery. A pool larger than the machine or the downstream budget does not make the work cheaper. If the jobs are independent HTTP calls and the limit is already the client's connection pool, add the bound there, not a second one that lies about capacity.

Learn Backpressure
What is it?

Backpressure is what happens when the producer is faster than the consumer. Something must give: the producer waits, the queue grows, work is rejected, or data is dropped.

In this project the producer waits. That is the honest default for a lab: you can see the wait on the timeline.

Why does it exist?

Without backpressure, a fast producer plus a slow consumer is an unbounded queue. The process looks healthy until memory or file descriptors run out — often far from the code that spawned the work.

How does Go implement it?

An unbuffered send is backpressure: the sender is parked until a receiver is ready. A buffer of N delays that moment by N values. It does not remove backpressure. It postpones the moment when it happens.

select {
case jobs <- job:
    // handed off — or waited for a worker
case <-ctx.Done():
    return
}
What does our Lab demonstrate?

Channel Lab: sendWaitMs is the producer waiting on the consumer. Worker Pool: jobs is unbuffered, so a send only completes when a worker receives. The gap between job_send_attempt and job_queued is that wait.

Realtime adds a second queue: live events go through a channel of 128. If that buffer fills, the goroutine that logs the event blocks on the send — or unblocks if the context is done. No extra goroutine per event, no unbounded event list.

Real-world use case

Payment webhooks arriving faster than the ledger can commit. Options: block accept (wait), a bounded queue plus a 429 (reject), or drop — which you only do when the data is a sample, not a payment. The Lab demonstrates wait.

Go vs Java

Block the producer

Go

Unbuffered or full-buffer send.

Java

BlockingQueue.put, or a full bounded queue in ThreadPoolExecutor.

Reject

Go

You would not send, or you select default / ctx.

Java

AbortPolicy, CallerRunsPolicy, offer() == false.

Reactive streams

Go

Usually explicit channels + select.

Java

Flow / Reactive Streams request(n) — a different protocol, same problem.

Java ThreadPoolExecutor with an unbounded LinkedBlockingQueue silently disables the 'pool is full' signal: threads stay at core size and the queue grows. That is the same mistake as make(chan T, 1_000_000) 'just in case'.

Common mistakes
  • A huge buffer to 'smooth things out' with no maximum and no metric.
  • Dropping work without recording it.
  • Applying backpressure only at the workers and letting the HTTP accept path grow without a limit.
When should I not use it?

If producer and consumer are the same speed and the batch is tiny, you will not see wait — that does not mean you should add a large buffer 'for later'. Add capacity when you have a measured burst, not as decoration.

Context Lab

live

The same worker pool, now under context.WithTimeout. Watch jobs complete, cancel mid-flight, or never start when the deadline fires.

  • context
  • select
  • timeout
Learn Context
What is it?

context.Context is a value that carries a cancel signal, a deadline, and (sparingly) request-scoped data. Contexts form a tree: canceling a parent cancels the children. Canceling a child does not cancel the parent.

Done() is a channel that closes when the context is done. Err() tells you why: canceled or deadline exceeded.

HTTP Request Context
        │
        ▼
   WithTimeout
        │
        ▼
   Worker Pool
    /    |    \
   w1    w2    w3
Why does it exist?

The client hung up. The deadline passed. A sibling call failed. Descendant work — the next SQL query, the next HTTP client, the next worker job — should stop instead of finishing a result nobody will read.

How does Go implement it?

Context flows downward: the caller creates or derives it and passes it in. This Lab never invents context.Background() in the middle of a request (RunWorkerPool only falls back to Background if the ctx argument is nil — the HTTP handlers always pass a real one).

ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()

// also: WithCancel, WithDeadline
select {
case <-ctx.Done():
    return ctx.Err()
case job := <-jobs:
    handle(job)
}
What does our Lab demonstrate?

Context Lab wraps the HTTP request context with WithTimeout. Jobs then land in three buckets: completed, canceled (a worker had the job when the context ended), or not started (the producer never handed them off). In-flight work uses time.NewTimer and select — not Sleep — so a cancel unblocks before the work duration elapses.

The same pool runs under REST and under the WebSocket. Cancel, closing the tab, or the server WriteTimeout on REST all cancel a context the workers already select on. Reason is 'completed normally', 'context canceled', or 'deadline exceeded'.

Real-world use case

A handler that queries Postgres and then a partner HTTP API. The browser tab is closed. r.Context() is canceled; the DB driver and the HTTP client should see that context and abort. Without it, both keep running and a slot in the pool is occupied for a user who is gone.

Go vs Java

Cancel a tree

Go

Parent context canceled → children Done.

Java

StructuredTaskScope or a shared CancellationToken-style flag. Not automatic on a raw Future.

Timeout

Go

WithTimeout / WithDeadline

Java

CompletableFuture.orTimeout, HttpClient timeout, Future.get(timeout)

Interrupt work

Go

select on ctx.Done() — cooperative.

Java

Future.cancel(true) sets interrupt. Blocking JDK calls may throw; CPU loops must poll.

Java interruption and Go context are both cooperative. Neither stops a tight loop that never checks. The difference is plumbing: Go passes one Context down every call that can block. Java historically used thread interrupts, then futures, then Structured Concurrency — closer, still not a first-class value on every signature.

Other languages

C# CancellationToken is the closest cousin: passed down, cooperative, linked tokens form a tree. JavaScript AbortSignal is the same idea for fetch. Python's asyncio.Timeout / CancelledError is task-scoped, not a value you thread through every helper unless you pass it.

Common mistakes
  • Calling context.Background() halfway down the stack and losing the request deadline.
  • Storing a Context on a long-lived struct — the request is gone, the context is not.
  • Starting work with ctx and never selecting on ctx.Done().
  • Forgetting cancel() after WithTimeout — the timer stays alive until it fires.
When should I not use it?

Do not use Context as a bag of optional business parameters. Do not cancel to mean 'success, we are done' when returning from a function is enough. If nothing can block, you may not need a context on that helper.

Learn select
What is it?

switch picks a branch from values and conditions. select picks among channel operations that can proceed. If more than one case is ready, the runtime chooses — do not depend on source order.

Why does it exist?

A worker is always in two worlds: there may be a job, and the context may be done. A sleep has the same split: the timer may fire, or cancel may arrive first. select is how those races stay explicit.

How does Go implement it?

Each case is a send or a receive (or default for non-blocking). A nil channel is never ready, which is how you disable a case.

select {
case job := <-jobs:
    handle(job)
case <-ctx.Done():
    return
}
What does our Lab demonstrate?

Two cases you can watch on the Context / Worker timelines. (1) A worker selects job receive vs ctx.Done() — that is how jobs stay 'not started' after the deadline. (2) doCancellableWork selects timer.C vs ctx.Done() — that is a canceled in-flight job instead of sleeping to the end.

The producer uses the same shape for send vs cancel. Live events use select on the events channel vs ctx.Done() so a stalled WebSocket cannot leak the pool.

Real-world use case

A cache fill that should return either the DB row, a timeout, or a shutdown signal. Three channels, one select, one return path per outcome.

Go vs Java

Wait on several events

Go

select on channels

Java

No language select. CompletableFuture.anyOf, CompletionService.poll, or NIO Selector (I/O only).

Fairness

Go

Ready cases are chosen pseudo-randomly.

Java

You write the polling order. anyOf does not promise which future won beyond the API.

A Java switch is still a switch. Coordinating 'queue or timeout or cancel' is library code. That is fine — it is just not the same construct, and it is easier to miss a signal when each one is a different API.

Common mistakes
  • Assuming case order is priority. It is not.
  • Using empty select{} as a sleep — it blocks forever.
  • Forgetting default when you needed a non-blocking try-send, or adding default when you needed to wait.
When should I not use it?

If there is only one channel and no cancel, a plain send or receive is clearer. select is for more than one way the call can proceed.

Realtime Lab

live

Worker Pool and Context Labs can stream events over a WebSocket. Press Run realtime on those cards — REST still returns the full result in one shot so you can compare both models.

  • WebSockets
  • context
  • single writer
Learn WebSockets
What is it?

A WebSocket starts as HTTP. The server answers 101 Switching Protocols. After that the connection is persistent and bidirectional: both sides send frames until someone closes.

Why does it exist?

REST is one request, one response. Polling repeats GET to see if anything changed. SSE is a server-to-client stream over HTTP. WebSocket is the option when both sides need to talk for the life of a session — a cancel button, a live timeline, a chat.

It is not always better. A dashboard that refreshes once a minute can stay on REST or SSE. CRUD stays on REST.

How does Go implement it?

Go's standard library does not ship a complete WebSocket implementation. This Lab uses gorilla/websocket only to upgrade the connection. net/http still owns the server. Concurrent WriteJSON on one connection is unsafe, so every outbound message goes through one writer goroutine.

// client → server: { "type": "start" | "cancel", "config": {...} }
// server → client: accepted | event | finished | error

writes := make(chan wsServerMsg, 16)
go func() {
    for msg := range writes {
        conn.WriteJSON(msg)
    }
}()
What does our Lab demonstrate?

Run realtime on the Worker Pool or Context cards opens GET /ws/lab/pool. REST endpoints are unchanged and still return the full result in one JSON document.

Flow: worker → PoolEvent → events channel (buffer 128) → single writer → WebSocket → browser. One experiment per socket. Cancel or closing the tab cancels the run context; a separate write context can still deliver finished after the pool stops. Read size is capped at 4 KB.

Worker
  │
  ▼
PoolEvent
  │
  ▼
events channel
  │
  ▼
single writer
  │
  ▼
WebSocket
  │
  ▼
Browser
Real-world use case

A job dashboard that shows which worker picked which encode, and a Cancel that must reach the server immediately. REST would either wait for the whole batch or require polling. SSE could stream events but would need a second channel for cancel. This page is that dashboard.

Go vs Java

Upgrade

Go

gorilla/websocket.Upgrader on net/http

Java

Jakarta WebSocket, Spring ServerEndpoint / WebSocketHandler

Writes

Go

Single writer goroutine — gorilla writes are not concurrent-safe.

Java

Same rule: synchronize or confine Session.getAsyncRemote() sends.

Cancel

Go

Client message or disconnect → context cancel.

Java

Session close / @OnClose. You still have to plumb that into your workers.

Spring WebSocket and Go+gorilla solve the same transport problem. Neither library cancels your thread pool for you. The useful part of this Lab is that the socket, the context and the workers share one cancel path.

Other languages

Browsers speak the same protocol everywhere. Node (ws), C# (ASP.NET Core WebSockets), Python (websockets / Starlette) — pick them for the same reasons, and keep REST for the one-shot API this site still exposes.

Common mistakes
  • Several goroutines calling WriteJSON on the same connection.
  • Ignoring disconnect and leaving the pool running (this Lab cancels on connection context).
  • No read/write limits — a client can stall or flood you.
  • Replacing every REST endpoint with a socket because it feels 'realtime'.
When should I not use it?

Do not upgrade a connection for a form that runs once and returns a number. Do not use WebSocket as a substitute for authentication, authorization or backpressure — those are still your problem after 101.

Persistence

live

Finished Worker Pool and Context runs are stored in PostgreSQL through database/sql. The experiment still returns immediately if the insert fails — GET /api/runs lists what was saved.

  • database/sql
  • repository
  • context
Learn PostgreSQL & database/sql
What is it?

database/sql is the standard-library API for SQL databases. It does not speak PostgreSQL itself. A driver registers a dialect; we open a handle; the handle talks to the server.

*sql.DB is not one connection. It is a handle that owns a connection pool. A query borrows a connection and gives it back. That is why we open one DB at startup and share it — we do not sql.Open per request.

database/sql
     │
     ▼
PostgreSQL driver (pgx stdlib)
     │
     ▼
PostgreSQL
Why does it exist?

The Worker Pool and Context Labs already produce a real result. Without a database that result dies with the HTTP response. Persist the finished PoolRun so GET /api/runs can show what actually ran — not a fake CRUD entity invented to justify SQL.

How does Go implement it?

sql.Open builds the handle. PingContext proves a connection works. SetMaxOpenConns / SetMaxIdleConns / SetConnMaxLifetime bound the pool the same way the worker pool bounds goroutines: a finite resource, a declared width.

ExecContext runs a statement you do not read back. QueryContext returns rows — Close them, range Next, then check rows.Err(). QueryRowContext is the one-row case; Scan of sql.ErrNoRows is 'not found', not a 500.

db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
db.SetMaxOpenConns(5)

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, `SELECT id FROM lab_runs WHERE id = $1`, id)
defer rows.Close()
What does our Lab demonstrate?

After RunWorkerPool returns, the handler maps PoolRun → LabRun and calls repository.Create. REST and WebSocket both do that once on the finished result — not on each job_started event. If the insert fails we log it and still return the experiment JSON.

The run context is often already done (deadline or cancel). Persist uses the HTTP request when it is still alive, or context.WithoutCancel plus a 2s timeout so the row we already computed can be written. GET /api/runs lists recent rows; GET /api/runs/{id} uses QueryRowContext and turns ErrNoRows into 404.

Real-world use case

An internal job runner that must answer 'what did we execute this morning?' after the worker process has moved on. The live dashboard is WebSocket; the audit trail is a table. This site now has that split: events stay in memory, the summary lands in PostgreSQL.

Go vs Java

API

Go

database/sql

Java

JDBC. Neither is an ORM.

Handle / pool

Go

*sql.DB — pool, not one connection.

Java

DataSource (HikariCP, etc.) vends Connection objects.

Statement

Go

QueryContext / ExecContext with $1 placeholders.

Java

PreparedStatement with ? placeholders.

Rows

Go

Rows.Next + Scan; defer Close.

Java

ResultSet.next(); close in try-with-resources.

Transaction

Go

sql.Tx — BeginTx, Commit, Rollback.

Java

Connection.setAutoCommit(false) or a platform transaction.

Spring JDBC and JPA/Hibernate sit on top of this layer. We are deliberately at the JDBC equivalent so the pool, the context and the SQL stay visible. JPA would hide the exact INSERT this Lab is meant to show.

A Go transaction is sql.Tx. We use one only in migrations: apply the SQL and record the version atomically. A LabRun insert is a single statement — wrapping it in Tx would be theatre.

Other languages

Node pg, Python psycopg, C# Npgsql — same server, different client APIs. The idea that is easy to miss in all of them is the same: the object you hold is usually a pool.

Common mistakes
  • Opening a new sql.DB on every request — that is a new pool each time.
  • fmt.Sprintf into SQL. Use $1. The limit query parameter is validated, then passed as an argument.
  • Forgetting rows.Close() — connections stay checked out and the pool starves.
  • Persisting with the canceled experiment context, then wondering why every timeout run fails to save.
When should I not use it?

Do not add an ORM, sqlc or a transaction around a single INSERT to look 'complete'. Do not persist every WebSocket event. Do not put SQL in the handler or import database/sql from internal/lab.

How everything fits together

These Labs are one stack, not isolated demos. A request arrives, a context sets the lifetime, a pool bounds the work, channels move jobs and events, select decides what happens next, a WebSocket can carry the timeline to the browser, and the finished PoolRun is stored through a repository.

Browser
 │
 ├── HTTP
 └── WebSocket
 │
 ▼
Handler
 │
 ▼
Context
 │
 ▼
Worker Pool
 │       │
 │       └── Events → WebSocket
 ▼
PoolRun
 │
 ▼
Repository
 │
 ▼
database/sql
 │
 ▼
PostgreSQL
Goroutine
A unit of concurrent execution. Workers are goroutines; so is the producer, the closer, and the WebSocket writer.
Channel
Communication and synchronization. Jobs, results, live events, and socket writes all cross a channel.
Worker Pool
A bound on concurrency. N workers, not one goroutine per job.
Backpressure
What happens when the consumer is slower. Here the producer waits on an unbuffered send.
Context
Lifetime and cancellation flowing downward from the HTTP request (and from Cancel / disconnect).
select
Coordination among channel operations: a job or cancel, work finished or cancel, an event or cancel.
WebSocket
The realtime transport. REST still returns the same PoolRun in one shot.
Repository
Maps a finished PoolRun to a LabRun and talks to the database. The experiment package never imports SQL.
sql.DB
A handle plus a connection pool — not one TCP connection. Same idea as the worker pool: bound access to a finite resource.
PostgreSQL
Where completed Worker Pool and Context runs live, so GET /api/runs can list them later.

REST API

The same content that renders these pages is served as JSON by the Go standard library. Add ?lang=pt to any endpoint to get the Portuguese version.