Project · 2026

Wasatext

A instant messaging webb app consisting of a REST API backend and a SPA VueJs Frontend, deployed with docker and cloudflare, both in local network and on the internet.

Wasatext is a WhatsApp-style messenger built end to end: a Go REST API + WebSocket backend, a Vue 3 SPA, SQLite as the only persistent store, and a Docker Compose stack that runs identically on localhost, in a home LAN, and behind a public Cloudflare Tunnel. The interesting part isn’t the CRUD-and-chat-bubbles surface — it’s the handful of decisions underneath that make the thing hold together like a real product instead of a weekend prototype.

One decision that shaped everything else

The browser talks to exactly one origin. nginx is the sole entry point: it serves the built SPA, proxies /api/* and /media/* to the backend, and terminates whatever transport wraps it (plain HTTP, LAN, or the Cloudflare Tunnel). No CORS, no backend-guessed base URLs, no separate hostname for the API. docker-compose up -d --build is the entire deployment story, on every target.

1. Ports & adapters, not layers-for-the-sake-of-layers

The backend follows hexagonal architecture — domain in the center, everything else plugged in through interfaces:

service/go
service/
├── domain/     entities: User, Message, Conversation, Reaction, Receipt — zero external deps
├── ports/      interfaces the domain needs (UserRepository, FileStorage, EventPublisher, AuthProvider…)
├── usecases/   one use case per file, ALL business rules live here
│   ├── auth/         message/       reaction/
│   ├── conversation/  receipt/      user/
├── adapters/   concrete implementations of the ports
│   ├── sqlite/  ws/  storage/  auth/  logger/
└── api/        httprouter handlers — HTTP↔usecase translation, zero business logic

The payoff isn’t theoretical. Opening usecases/message/send_message.go reads like the actual business rule — no SQL, no JSON marshaling, no HTTP status codes in the way. Swapping SQLite for Postgres, or REST for gRPC, touches exactly one adapter and never the use cases.

The pattern that earns its keep the most is a decorator on ports.FileStorage:

fileStorage := storage.NewImageOptimizer(localStorage, 1600, map[string]int{
    "avatars": 640, "groups": 640,
})

local.go just writes bytes to disk. ImageOptimizer wraps it and resizes/recompresses transparently — callers of Save() have no idea optimization is happening. Bolting on S3 later is a new adapter behind the same interface; the decorator, and every use case that calls it, doesn’t change.

2. REST API, boringly predictable on purpose

Flat, resource-oriented, one Bearer token for everything protected:

ResourceRoutes
SessionPOST /session, POST /session/google, DELETE /session
UsersGET /users?q=, GET /users/:userId, PUT/DELETE /users/me/photo
ConversationsGET/POST /conversations, PUT /conversations/:convId/name, POST /users/:userId/conversation
MessagesGET/POST /conversations/:convId/messages, POST .../forward, GET .../receipts
ReactionsGET/PUT/DELETE /conversations/:convId/messages/:msgId/reactions
RealtimeGET /ws (upgrade)

Routing runs on httprouter’s radix tree — path matching in time linear to the path length, not to how many routes exist — which is overkill at this scale but costs nothing over a regex router, so there’s no reason not to take it.

The one gotcha worth remembering

/me has to be registered as a literal segment before any :userId/:convId wildcard, or the router happily matches “me” as if it were an id. Easy to get backwards, annoying to debug from a 404 alone.

3. Realtime without a message broker

A single in-memory hub owns a userID → connections map — deliberately built as an actor: only the hub’s own goroutine ever touches that map, so there’s no mutex on the data itself, only on the register/unregister/broadcast channels feeding it. Multi-device is free: every open tab or phone for a user gets every event.

Backpressure is handled the unglamorous way that actually matters in production: each client has a bounded 64-message send buffer. If a client can’t keep up, its connection is closed, not queued indefinitely — one slow phone on bad LTE should never stall delivery to everyone else. The client itself never sends anything after the upgrade; the server is write-only, and the read loop exists purely to detect the socket closing.

4. Data-model decisions that actually matter at scale

This is the part I’d want a senior engineer to review, because it’s where “it works” and “it doesn’t leak information / doesn’t fall over at 10x” diverge:

  • Two IDs per message, on purpose. The autoincrement id is internal — used only for ordering and comparisons. The only ID the API ever returns is a UUID (pub_id). A sequential public ID would let anyone infer how many messages exist in total just by diffing two responses; the UUID gives away nothing.
  • Watermarks instead of a receipt row per message. The naive design — one row per (message, user) — is O(N messages × M users) in storage and grows without bound. conversation_watermarks instead keeps one row per (conversation, user) holding last_read_id / last_delivered_id. Space drops to O(N users), and “how many unread” becomes a single indexed range count:
SELECT COUNT(*) FROM messages
WHERE conversation_id = ? AND sender_id != ? AND id > COALESCE(last_read_id, 0)
  • Sessions are hashed, not stored. sessions.id is the SHA-256 of the raw token; the token itself is never persisted. A database leak doesn’t hand out reusable sessions.
  • Soft delete everywhere. messages.is_deleted and conversation_members.left_at are nullable flags, never a physical DELETE. History stays consistent for anyone who was actually in the conversation when a message was sent, and every foreign key is ON DELETE RESTRICT — orphaned rows are structurally impossible, not just discouraged by convention.

None of these are exotic. They’re the difference between a schema that “passes the demo” and one that doesn’t need a redesign the first time it meets real traffic or a security review.

5. Performance work that showed up in real numbers

Phone photos land as 2–8 MB JPEGs. Left alone, opening a chat full of them on mobile data is a multi-second wait per image. Every upload goes through the same ImageOptimizer decorator from section 1: EXIF auto-orientation baked in at encode time (no client-side EXIF reading needed), Lanczos resize to 640px (avatars/groups) or 1600px (chat attachments), JPEG requality at 80 — and if the result isn’t actually smaller, the original is kept, so the pipeline never makes things worse.

2.6 MB → 53 KB on a real test avatar. Re-running the same optimizer as a one-off CLI tool (cmd/optimizeuploads) against pre-existing uploads recovered 92% of disk space on a real dataset.

Caching is the other half: immutable, hashed Vite bundles get max-age=31536000, immutable; index.html gets no-cache so every deploy is visible instantly; UUID-named media paths — never overwritten — get the same immutable treatment, so a photo you’ve already seen is never fetched twice.

6. A look at the app

Login / registration
Conversation list
Chat with reactions & receipts

7. Docker Compose, and nothing else to remember

Three services, docker-compose.yml:

  • backend — no ports published on the host at all; only frontend can reach it over the Docker network. Volumes for ./uploads and ./data (SQLite lives on disk as a single file — backup is cp).
  • frontend — the only published port. nginx serves the pre-built SPA and reverse-proxies /api and /media.
  • tunnel — opt-in via --profile cloudflare, points cloudflared at http://frontend:80. One hostname, no separate API endpoint to configure.

Both images are multi-stage (Dockerfile.backend / .frontend): full toolchain (Go+gcc, Node+yarn) only in the build stage, Alpine + a static binary or nginx + a dist/ folder in what actually ships. Whichever environment I’m deploying to — my own machine, the home LAN, or a public tunnel — the command is the same one:

docker-compose up -d --build

That’s the whole point of the single-origin decision from the top of this page: nothing environment-specific is baked into the images, so there’s nothing to remember to change.

Hexagonal architectureGo + httprouter + SQLiteVue 3 · Pinia · WebSocket-92% media storage