System design

media-service, a file service under demo/media-service

Also a real, running thing, not a made-up company: a standalone Go service at demo/media-service in this site's own repo, and a real client of it — demo/chat-demo's Wavelink backend, covered in the chat application write-up. This one is about a narrower question: what does file upload/storage look like when it's built as its own service instead of a feature bolted onto the app that happens to need it first.

Topic
System design, working code
Source
demo/media-service in this repo (Go) — consumed by demo/chat-demo's backend
Updated
August 2026

Covered


What it is, and why it's not inside the chat app

media-service owns uploaded files: images and generic documents for now, video named as future scope, not built. It knows about owners and media objects. It has never heard of a conversation, a message, or a group — that vocabulary belongs entirely to Wavelink, the chat app that happens to be its first caller.

That separation is the actual design decision this write-up is about, more than any single endpoint. Every real chat product splits this the same way — WhatsApp's media servers, Slack's Files API, Discord's CDN are all products the messaging layer calls, not code living inside it — for a reason that shows up immediately once you list what each side actually needs:

Messaging
connection fan-out, ordering, low latency, mostly-idle sockets held open
Media
storage throughput, bandwidth, bursty CPU for thumbnailing — nothing about a live connection

Different bottlenecks mean different scaling knobs, and bundling them means scaling one forces you to pay for the other. That's the case for a separate deployable service in general, independent of what either side happens to be written in.

The implementation here is Go: a presigned-URL issuer and an image-resize worker pool are both concurrency-heavy, I/O-bound jobs — a lot of short-lived requests and a pool of workers decoding/encoding images in parallel — which is exactly what goroutines and channels are built for, with a mature S3 SDK and standard-library-adjacent image support to go with it.

Functional requirements

Non-functional requirements

Features actually built

Architecture

The browser never talks to media-service directly, and never holds its service token — Wavelink's backend is the only caller, proxying the small JSON exchanges (create, complete, resolve) while staying out of the way entirely for the actual bytes. That's the one leg that bypasses both app servers: the browser PUTs straight to object storage using a presigned URL, and later GETs the same way.

Browser (React SPA) fetch, no service token x-user-id Wavelink backend Elixir, holds the service token Bearer token media-service Go, stateless Storage interface one S3-compatible client MinIO local dev, in Docker AWS S3 prod presigned PUT/GET, straight to storage — bytes never touch either app server
Two trust boundaries, not one: the service token gates who may call media-service at all (only Wavelink's backend), while the presigned URL is what actually lets bytes move, scoped to one object and a few minutes.

An upload, end to end

Nothing is trusted as real until it's verified against the source of truth — here, the object store itself, not a database row or a client's say-so.

Client POST /api/media media-service: create pending upload_url PUT bytes, direct to storage object storage complete POST .../complete media-service: owner_id must match stat object, size must match declared ready, or processing if image/*
Two verification points, not one: the presigned URL itself signs content-length, so a truncated or mismatched upload is rejected by the storage layer before it ever reaches this service — and complete's own size check catches anything that still slipped through.

Thumbnail generation, without a queue service

Image uploads land in processing, not ready, once complete confirms the bytes landed. Turning that into ready is internal/media.Worker: an in-process goroutine pool, not a call to SQS or a separate queue service — the pragmatic v1 shape for a service where each instance only needs to keep up with its own share of upload volume.

The design problem an in-process queue creates is drops: what happens to a job if the queue is full, or the process restarts mid-job. The answer here is at-least-once, not exactly-once, done cheaply: Enqueue never blocks the HTTP request that triggered it — a full queue just drops the enqueue — and a periodic sweep (Store.ListByStatus(processing)) re-submits anything still processing, on a timer and once immediately at startup. A dropped enqueue and a mid-job crash both self-heal on the next sweep, without either being handled as a special case.

record: processing Enqueue, never blocks jobs channel worker pool (N goroutines) decode → scale down → re-encode upload thumbnail, mark ready decode fails → failed, original stays usable sweep: startup + every 30s ListByStatus(processing) → re-enqueue
Every image ends in ready or failed, never stuck in processing — a decode failure doesn't touch the original, which is already safely in storage by the time thumbnailing even starts.

Integrating with Wavelink

A message can carry an optional media_id instead of, or alongside, text. ConversationChannel never touches file bytes and never even calls media-service's storage layer directly — it calls Wavelink.Media.get/1 to check two things before durably writing the message: the sender actually owns that media id, and its status is at least processing (the original landed, even if a thumbnail hasn't finished). Attaching a still-pending id — an upload that was created but never confirmed — is rejected before the message is ever durably written, rather than writing a message that points at bytes which might not exist.

A rejected attach doesn't silently drop the message: the channel pushes back %{status: "rejected", reason: ...} on the same ack the client already listens on for send confirmation, and the UI removes the optimistic bubble rather than leaving it stuck.

Resolving a media_id back into something renderable is deliberately not baked into the message payload — a signed URL is only good for a few minutes, but a message lives forever. Instead the client calls GET /api/media/:id (proxied the same way) whenever it actually renders that message, and again right before opening the full image or downloading a file, so a bubble scrolled past five minutes ago still resolves to a working link when it's finally viewed.

Complexity worth naming

Tech stack

Other things worth knowing


All system design notes · Designing a chat application · Home