GoEncoreNext.jsWebSocketPostgreSQL

Watching Money Move in Real Time — Building a WebSocket Transaction Visualizer

Lucas Sim · webhook-playground

Live demo →

I've always found it interesting how financial transactions feel instant from the user's side, but under the hood they're a chain of discrete steps, each one capable of failing. Balance check, daily limit check, receiver verification, fund transfer, confirmation. Five things that happen in sequence, each with its own potential failure mode.

So I built webhook-playground: a full-stack project that makes that hidden pipeline visible. You enter a sender, receiver, and amount. A job is created. Then you manually trigger each step and watch the result animate in real time: success, failure, or retry; driven entirely by WebSocket events from the backend.


The Real-World Architecture Behind a Bank Transfer

Here's something most developers don't think about: real banks don't run a single monolithic service that knows everything about you. They operate in microservices, and for good reason -> security and data isolation.

When you tap "Transfer" in your banking app, the request touches multiple independent services, each owned by a different team, each with access to only the data they need:

The developer working on the Limits Service cannot query your balance. The developer on Account Verification cannot see your transaction history. This isn't just good architecture, it's a deliberate security boundary. If any one service is compromised, the blast radius is contained.

What connects these services isn't a direct function call. It's events. Each service completes its step, emits an event, and the next service in the chain picks it up. The whole pipeline is event-driven, asynchronous, and ordered. That's exactly what this project visualizes.


What Problem Does This Solve?

There's no shortage of CRUD tutorials. But most of them skip the interesting parts:

This project is my answer to that gap. The failures are randomized on purpose, and the five steps run in a single backend rather than separate deployed services. But the patterns are real: each step is an isolated unit of work with its own success/failure logic, communicating via events rather than direct calls.


Tech Stack

LayerChoiceWhy
BackendGo + EncoreNative PostgreSQL, automatic API docs, built-in tracing
RealtimeWebSocket (gorilla/websocket)Raw, bidirectional, zero polling
DatabasePostgreSQL (managed by Encore)Persistent job + step state
FrontendNext.js (App Router)SSR for initial hydration, client-side WS from there
StylingTailwind CSSFast iteration
Backend DeployEncore CloudNative from Encore's
Frontend DeployVercelZero-config Next.js deployment

One deliberate choice: Encore as the backend framework. Encore manages your PostgreSQL instance automatically — locally on encore run, and in production on deploy. No connection strings in code, no .env juggling for the database. It also gives you built-in distributed tracing at localhost:9400 while you develop, which was genuinely useful when debugging the async step flow.


How It Works

###1. Creating a Job

POST /jobs accepts a sender, receiver, and amount. It creates a jobs row and five steps rows — all in idle state — then returns the full job payload.

go
// jobs/jobs.go
//encore:api public method=POST path=/jobs
func (s *Service) CreateJob(ctx context.Context, req *CreateJobRequest) (*JobResponse, error) {
    jobID := uuid.New().String()
    title := fmt.Sprintf("Transfer: %s → %s", req.Sender, req.Receiver)
    _, err := db.Exec(ctx, `INSERT INTO jobs (id, title, amount, sender, receiver, status)
        VALUES ($1, $2, $3, $4, $5, 'pending')`, jobID, title, req.Amount, req.Sender, req.Receiver)
    // ... insert 5 steps with status='idle'
}

The frontend navigates to /jobs/:id, fetches the initial state via GET /jobs/:id, then opens a WebSocket connection and switches entirely to event-driven updates.

###2. Triggering a Step

POST /jobs/:id/steps/:stepId/trigger is where the interesting validation lives. If the step is already processing409 Aborted. If it already succeeded400 Failed Precondition. If the previous step hasn't succeeded yet → 400 Failed Precondition.

Once validation passes, the handler marks the step as processing, broadcasts a step.processing WebSocket event, spawns a goroutine, and returns immediately with 202 Accepted. The client never waits for the outcome — it just watches for events.

go
hub.Global.Broadcast(id, hub.WSEvent{
    Event:   "step.processing",
    JobID:   id,
    StepID:  stepId,
    Message: "Processing...",
})
go processStep(id, stepId, stepOrder, sender, receiver, amount)
return &TriggerResponse{Accepted: true}, nil

###3. The Simulation Engine

steps/engine.gohandles each step in a goroutine. It sleeps for 2 seconds (simulating real processing time), then randomly determines the outcome based on each step's configured failure probability.

StepFailure Rate
Check Balance20%
Check Daily Limit20%
Verify Receiver Account10%
Process Transfer10%
Confirmation0%

On failure, the step resets to idle after 2 seconds — so the user can hit RETRY. On success, the engine checks if all steps are done. If they are, it marks the job completed and broadcasts job.completed.

###4. The WebSocket Hub

The hub is a package-level singleton that maps job IDs to a slice of active connections. Broadcastwrites to every connection registered for a given job. If a write fails (client disconnected), it's silently removed — no blocking, no panics.

go
// hub/hub.go
type Hub struct {
    mu      sync.RWMutex
    clients map[string][]*websocket.Conn
}

func (h *Hub) Broadcast(jobID string, event WSEvent) {
    data, _ := json.Marshal(event)
    h.mu.Lock()
    defer h.mu.Unlock()
    conns := h.clients[jobID]
    alive := conns[:0]
    for _, c := range conns {
        if err := c.WriteMessage(websocket.TextMessage, data); err == nil {
            alive = append(alive, c)
        }
    }
    h.clients[jobID] = alive
}

###5. WebSocket Events

All events use a consistent envelope:

json
{
  "event": "step.processing | step.success | step.failed | job.completed",
  "jobId": "uuid",
  "stepId": "uuid",
  "message": "Human readable message"
}

The frontend's useWebSocket hook receives these and updates step state locally — no re-fetching from the server required.

###6. What the UI Shows

Each step card has five visual states: idle (locked), ready (pulsing blue border), processing (amber + spinner), success (green checkmark), and failed (red X with RETRY button). State transitions are driven entirely by WebSocket events — the UI never polls.

Think of each card as a window into a different microservice. In a real bank, these are separate codebases, separate databases, separate teams. Here, they're separate units of logic that communicate through the same event-driven pattern.


Why WebSockets Over Polling?

The straightforward alternative would be polling GET /jobs/:id every second. It would work. But:


What Encore Brings to the Table

The Cleanup Cron

A weekly cron job prunes jobs older than 7 days. Encore's cron API is declarative — you define the schedule and point it at a private endpoint. It doesn't run locally or in preview environments — only in production, exactly as intended.

go
var _ = cron.NewJob("weekly-cleanup", cron.JobConfig{
    Title:    "Clean up old jobs",
    Schedule: "0 0 * * 0",
    Endpoint: CleanupJobs,
})

//encore:api private
func CleanupJobs(ctx context.Context) error {
    _, err := db.Exec(ctx, `
        DELETE FROM steps WHERE job_id IN (
            SELECT id FROM jobs WHERE created_at < NOW() - INTERVAL '7 days'
        )`)
    // ... delete jobs
}

TL;DR

Real banks run microservices. The developer who works on daily limit checks has no access to your account balance. Each service owns its slice of data, and the whole pipeline is stitched together through events — not direct calls, not shared databases.

webhook-playground makes that invisible architecture visible. Trigger a step, watch it process, see it succeed or fail, and retry if it doesn't. Each card on screen represents what would be an independent service in production — isolated, ordered, communicating through events.

The interesting engineering is in the combination: a 202-returning HTTP endpoint that hands off to a goroutine, a concurrency-safe WebSocket hub that broadcasts state changes to all connected clients, and a frontend that reacts to events rather than polling. Encore handles the infrastructure glue so the code stays focused on the domain logic.


Built with Go, Encore, Next.js, PostgreSQL, and gorilla/websocket.