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:
- ›Balance Service -> knows your account balance. Nothing else.
- ›Limits Service -> knows your daily transaction cap. Not your balance, not your account details.
- ›Account Verification Service -> knows whether a given account number exists and belongs to a real person. Not what's in it.
- ›Transaction Service -> executes the fund movement between accounts.
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:
- ›What does an event-driven, asynchronous state machine actually look like end-to-end?
- ›How do you model isolated, ordered services where each step only knows what it needs to?
- ›How do you push state changes from server to client without polling?
- ›How do you handle failure and retry in a sequential pipeline?
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
| Layer | Choice | Why |
|---|---|---|
| Backend | Go + Encore | Native PostgreSQL, automatic API docs, built-in tracing |
| Realtime | WebSocket (gorilla/websocket) | Raw, bidirectional, zero polling |
| Database | PostgreSQL (managed by Encore) | Persistent job + step state |
| Frontend | Next.js (App Router) | SSR for initial hydration, client-side WS from there |
| Styling | Tailwind CSS | Fast iteration |
| Backend Deploy | Encore Cloud | Native from Encore's |
| Frontend Deploy | Vercel | Zero-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.
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 processing → 409 Aborted. If it already succeeded → 400 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.
###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.
| Step | Failure Rate |
|---|---|
| Check Balance | 20% |
| Check Daily Limit | 20% |
| Verify Receiver Account | 10% |
| Process Transfer | 10% |
| Confirmation | 0% |
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.
###5. WebSocket Events
All events use a consistent envelope:
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:
- ›Latency: WebSocket events arrive the moment they're broadcast. Polling introduces up to 1 second of lag per step.
- ›Load: With WebSockets, the server pushes to all connected clients in one broadcast call. With polling, every client fires its own request.
- ›State machine clarity: The event stream is the source of truth for the UI. Polling creates a reconciliation problem — with events, you react to exactly what happened.
What Encore Brings to the Table
- ›Zero-config PostgreSQL locally. Run
encore runand your database is provisioned and migrated automatically. No Docker Compose, no manualcreatedb. - ›Structured API definitions. The
//encore:apiannotation generates API documentation, handles JSON marshaling, and integrates with tracing — all from a regular Go function signature. - ›Built-in observability. Every request is traced at
localhost:9400. When the async goroutine fires and the WebSocket event lands, you can see the full timing without adding any instrumentation yourself. - ›Docker build for self-hosting.
encore build docker webhook-playground:latest --arch=amd64produces a production-ready image. Deploying to Render was straightforward from there.
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.
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.