Loading page
Digitalgrub
Build notes

We built our own chat app in seven weeks

By Sara · · 18 min read

Digitalgrub Chat31 July – 20 September 2026

We run a small sales and leads business. Every client conversation, every file, every voice note lived in Telegram groups. When Telegram stopped being something we could promise would work tomorrow, we didn't move to another app — we built one. Here is the whole thing, including the bugs that cost us the most.

47

days, first commit to App Review

154

commits

2

languages, English and Tamil

3

surfaces, one codebase

1

server we control

What it does

  • Messages, replies, reactions, edits, delete for everyone and typing indicators — plus disappearing messages on a timer when a chat needs them.
  • Sent and read are real. There is no “delivered” tick, because nothing underneath can honestly promise one.
  • Voice and video calls, group calls and screen sharing. Every call runs through our own machine, so the media never leaves it.
  • Meetings whose door is a link. A client joins from a browser with a name, no account and nothing to install.
  • English and Tamil throughout — 417 strings, both complete, because plenty of the people using it read Tamil faster.
  • Notifications carry an id, not your message. The text never passes through Google or Apple.
A conversation in Digitalgrub Chat, with voice and video call buttons in the header
1 / 3
A conversation. Voice and video calls sit in the header, next to the timer for disappearing messages.

The week we decided

The trigger wasn't dramatic. Telegram simply became a thing that worked for some of us, on some networks, some of the time.

I'm not going to argue about why here. There are six of us — Karthi, Hafeez, Bharath, Harish, Durga and me — and what mattered to all six was this: the tool holding every conversation we had with a paying client had turned into something none of us could guarantee. A client asks a question on a Tuesday and you can't answer, and the reason you give them is that an app you don't control is having a moment. Twice is a pattern. After that it's your problem, not the app's.

The obvious moves were all bad in the same way.

WhatsApp would have worked, and we'd have had exactly the same conversation again in two years with a different logo. Slack and Teams are priced per seat, which is fine for a company that only has staff and awful for one that wants to put its own clients into the same workspace. Every SaaS option had the same shape: our conversations, somebody else's machine, somebody else's terms.

And there was a second reason, which is really the first reason. We sell software to local businesses. A chat app we own isn't overhead — it's a product with a demo we can run from a laptop in somebody's shop.

So: own the server.

The decision that actually made it possible

The one that mattered more was: don't invent a protocol.

Chat looks simple until you write down what it has to do. Delivery states. Read receipts that survive a reinstall. Edits and deletions that propagate. Reactions. Typing indicators. History pagination that doesn't repeat or skip. Membership and permissions. Media. A sync model that can resume from a three-day gap over a bad connection. Every one of those is a week, and every one of them is a week you spend not building the thing your business is actually about.

We went with Matrix, running Synapse — its reference homeserver — on our own box.

What we looked at and didn't take:

  • Rocket.Chat / Mattermost. Complete products, excellent ones. But they arrive with their own opinions about everything, and our product idea was mostly about having different opinions. Making them ours would have been a fight with somebody else's codebase, forever.
  • XMPP. Mature, federated, and with a mobile story that has been "almost there" for longer than I've been working.
  • Our own protocol over WebSocket, with Postgres behind it. This is the one that's genuinely tempting for about an afternoon. Then you re-read the list above and notice you've budgeted seven weeks for read receipts alone.

Matrix gave us, on day one and for free: rooms, membership, power levels, read receipts, typing, redactions, edits, reactions, history pagination, media storage, server-side search, push rules, and a federation story we aren't using but haven't closed off.

The bill comes in two parts. The protocol's vocabulary leaks into your app if you let it — and it will try. And nobody outside this industry knows what a homeserver is, nor should they ever need to.

So we made a rule and held it: no Matrix vocabulary in the user's face. Nobody on our team has ever had to type a room id. They type a name. Underneath, the app resolves a room, reuses a direct chat if one already exists, and gets on with it. The word "Matrix" doesn't appear anywhere in the interface.

Three client surfaces built from one Flutter codebase talk over HTTPS to a single server running Caddy, Synapse and PostgreSQL, plus four services added to the core: a push gateway extended in-house, a LiveKit SFU, and two small services of our own for guest tokens and administration.
Everything inside the dashed line is one machine and one Compose file. Caddy is the only way in over HTTPS; the SFU opens its own ports for media. The bottom row is what we added to the core.

The middle of that diagram is Synapse and PostgreSQL, pinned to exact versions, doing what they're good at. The bottom row is what we added. LiveKit runs as it ships. Sygnal, the standard push gateway, we extended. The guest-token and admin services we wrote from scratch. The extension and the two services come to about 800 lines of Python, and the two services use nothing but the standard library. Each one exists because there was a specific thing the protocol doesn't do and our product needed.

One codebase, three places

The client is Flutter — one tree producing an iOS app, an Android app and a web client.

The layout is feature-first (authentication, chats, conversation, calls, meetings, groups, profile…) with one rule enforced across all of them: Matrix SDK types never reach a widget. Every feature declares a repository interface in its domain layer and the Matrix implementation lives behind it. Client, Room and Event don't appear in presentation code at all.

That rule cost real effort in week one and paid for itself in week three. When we changed how calls move media — and we changed it substantially — the call screen didn't move. It was still talking to a CallRepository. The thing underneath had been replaced.

Two other things worth saying out loud:

Tamil isn't a translation layer, it's a first-class locale. 417 strings, English and Tamil, both complete, both checked in. Plenty of the people who'll use this read Tamil faster than English, and an app that makes them work in their second language to do their job is a worse app.

Tests exist because reinstalling a debug build on a phone is not a test strategy. 432 widget and unit tests, 11 golden images, 44 Python tests over the server-side code. The goldens pin down what's tedious to check by eye: spacing, and states a screen is only in briefly, like a chat row with and without a mention.

Messages are the easy part, and the easy part isn't easy

Sending a message is trivial on good wifi. It isn't trivial in a lift in Madurai on 4G, which is where people actually use it.

Our send path is a durable outbox, not a request. A composed message is written to local storage first, with a client-generated transaction id, and only then attempted.

composed  →  queued  →  sending  →  acknowledged
                ↑          │
                └──────────┘   failed: retry with the SAME transaction id

That transaction id is what makes retry safe. A retry after a timeout is not a new message, and a network that dropped the response rather than the request must not produce two messages in the room. Matrix handles this properly if you hand it the same id.

The other thing we were deliberate about is what we claim to know:

The UI distinguishes local pending, failed, server-acknowledged, and read states. It must not label a server acknowledgement as recipient-device delivery.

docs/architecture.md, from the first commit

I'd defend that line in an argument. Matrix has no universal per-device delivered receipt. Every app showing you two grey ticks that mean "delivered to their phone" is either using a protocol that guarantees it or telling you something it doesn't know. We show sent, and we show read, because read receipts are real. We don't show delivered, because we can't.

Here's the whole product-to-protocol mapping, which is most of the app's behaviour on one page:

What the person doesWhat actually happens
Sends textm.room.message, msgtype: m.text
Repliesm.relates_to.m.in_reply_to.event_id
Reactsm.reaction with rel_type: m.annotation
Editsa replacement message with rel_type: m.replace
Deletes for everyonea redaction targeting the original event
Deletes for themselvesa local preference — nothing leaves the device
Is typingephemeral m.typing
Readsm.read receipt plus the fully-read marker
Starts a direct chata private room plus an m.direct account-data entry
Blocks someonem.ignored_user_list in account data
Is made an adminm.room.power_levels

None of that is our invention. That's the point.

Notifications took longer than calls

I want to be precise about this because it surprised me: getting a notification onto a locked phone, correctly, on both platforms, took more of my life than building video calling did.

It starts with a decision that makes everything harder and that I'd make again. Message text never passes through Google or Apple.

Push on mobile has exactly one door and Google and Apple own it; you can't reach a sleeping phone any other way. But you can control what you hand them. Matrix pushers support an event_id_only format where the payload is an event id and a room id and nothing else. We use it everywhere.

Synapse sends only an event id and a room id to our own push gateway. The gateway asks Synapse what type that event is, then wakes the device through Google or Apple with a data-only message. The phone fetches the content itself over its own encrypted connection, so no message text ever passes through Google or Apple.
The payload is an id, not a message. Google and Apple carry a wake-up; the phone fetches the actual content over its own connection to the homeserver.

The consequence is immediate and expensive: the operating system can no longer draw your notification. It has nothing to draw. So the app has to be woken by a data-only message, sync, read the event out of its own cache, and render the notification itself — on both platforms, including when the app has been dead for two days. That's a real amount of work, and it's the price of the payload being an id.

Then there's a second problem, which is the good one.

A locked phone should ring like a phone for a call and buzz like a message for a message. To do that, the gateway has to know which one it's holding. The obvious route is to have the client's push rule set a tweak on call events. That route is a dead end, and Synapse says so in its own source:

event_id_only doesn't include the tweaks, so override them
tweaks = {}

synapse/push/httppusher.py

The tweaks are stripped precisely because we chose the private payload format. Our gateway is handed an event id and a room id and has to answer "is this a call?" from that.

We extended Sygnal, the standard Matrix push gateway, with our own pushkins. They ask Synapse directly: given this event id, what type is it? The two containers are neighbours on the same private network, the answer is cached briefly so a roomful of ringing phones costs one lookup rather than one each, and any failure quietly means "not a call" — a ring arriving as a message is better than a message that never arrives.

Three more, recorded here so they cost you nothing:

  • An APNs key uploaded for the wrong environment fails silently. It doesn't error. It behaves exactly as if the key was never uploaded at all. And a key's environment can't be changed after upload — you generate a new one.
  • A push gateway keeps one entry per app id. If iOS and Android both register as com.digitalgrub.chat, one of them gets the other's answer. The app id has to carry a platform suffix, and our first version didn't.
  • On the web, serviceWorker.ready is not your service worker. It resolves to Flutter's own root worker. We subscribed web push against it and got a subscription that was real, valid, and attached to the wrong thing. You have to keep the registration register() hands back and wait for it to go active yourself.

Calls

Every call goes through a LiveKit SFU running on our own machine, beside Synapse. Matrix is the control plane: it carries who is in the call and the ring. It never touches a byte of media.

Matrix carries only call membership state and the ring — a few hundred bytes. All audio and video runs over a self-hosted LiveKit SFU on the same machine, including one-to-one calls.
Matrix carries a few hundred bytes. The SFU carries everything else. Including one-to-one calls, which is not the textbook answer.

The choice worth explaining is that one-to-one calls also go through the SFU. The textbook answer is peer-to-peer for two people and an SFU from three up, because P2P is cheaper for the server. The textbook answer also gives you two media paths, two sets of bugs, and a transition in the middle of a live call at the exact moment a third person joins. We have one media path for every size of conversation, nothing to switch over, and it costs us some bandwidth on a machine we already pay for. Element X makes the same trade. For a team our size it's the right one.

Media never leaves the server, which is the whole reason we're doing this.

Three audio bugs, in the order we found them

The call came out of the room instead of the ear. A voice call defaulted to speakerphone, which isn't what a phone call is. Fixing it once wasn't enough — the route gets reset underneath you, so the real fix is to hold the route rather than nudge it at the start. Then group calls turned out to want the opposite, because a group call is a speakerphone call. The default now depends on the kind of call and whether there's video.

It's microphone-only, deliberately, and the reason is in the file:

Adding the camera type would keep video publishing from a phone whose owner has walked away to another app, and a camera that keeps transmitting when you leave the call screen is not a feature. Audio continuing is what a phone call means; video stopping is what people already expect.

CallForegroundService.kt

A real phone call kills the microphone and doesn't give it back. When the GSM call takes the mic and hands it back, the track is still there, still "enabled", and producing nothing. The app now notices how long audio was paused and, past a threshold, cycles the microphone off and on and re-asserts the audio route.

The review that found ten faults in one day's work

At one point I stopped adding features and went back through the previous day's work properly. That review found ten real bugs. Two are worth repeating because neither would ever appear in a test:

  • Backing out of a call while it was still connecting left a marker behind that silenced every later ring for that room — banner and system ring alike. The room simply stopped being able to ring you, permanently, and nothing anywhere said so.
  • Answering a call, then opening the app days later from the recents list, replayed that answer: a brand new call started, with a live microphone, from a tap made on Tuesday.

You find these by using the thing every day, with people who'll tell you when it's annoying.

Meetings, because the link is the product

Nobody has ever been talked into installing an app to join a meeting. The invitation is the product, so meetings work the way people already expect: a button makes a room whose door is a link.

Joining codes look like abc-defg-hij — three groups of lowercase letters. Not room ids, not UUIDs, not anything anyone has to read carefully over a phone call.

Guests never touch Matrix at all. A separate token service checks that the room behind the link really is a meeting, then signs a token admitting that person to that one room's media and nothing else. Anyone with the link can join — that's the entire point of a meeting link, and why the codes are unguessable. A link pointing at a team chat gets a hard no, because only rooms carrying the meeting marker are ever granted a token. A name is enough; there's no account.

The piece of this I like most is the parser, because it's where the code respects how people actually behave:

People arrive with the code alone (abc-defg-hij), the full link, the link with a trailing slash or query, or the whole invitation email with the link somewhere in the middle. All of those mean the same meeting, so all of them resolve; anything without a code in it resolves to null.

meeting_code.dart

Somebody is going to paste the entire email into that box. That isn't user error. That's a Tuesday.

The web client, and a click that went nowhere

The web client is the same Flutter tree, served at the root of our domain. It's also where the single most frustrating bug of the project lived.

Symptom: you click a chat in the list and nothing happens. You click again. Still nothing.

Three explanations look identical from outside — the click never arriving, the click arriving at coordinates the app disagrees with, or the click arriving and being swallowed by something invisible. Guessing between them had already cost more than one wrong fix. That's when I stopped guessing and built an instrument instead.

Opening the page with ?diag=1 logs every pointer down: where it landed, the viewport size, the lifecycle state, and the full hit path — each entry recorded as a size and a position rather than a widget type name, because release builds minify type names and the release build is where this happened. It costs nothing when the flag is absent; the widget returns its child untouched.

It earned itself on the first run:

A click at x=160, inside the painted list, found only full-viewport boxes, while a click at x=1000 found a 288-wide chat row at x=730. Paint and hit test agree with each other and disagree with the window.

commit 9db8595

So the layout was drifting, not the compositing. And the actual cause turned out to be simpler and more annoying than any of our three theories:

The fix is to ask for one frame when the page becomes visible again. It costs nothing if the display was already correct. We scoped it to web only — Android and iOS repaint on resume already, and an extra warm-up frame there is a hitch bought for nothing.

The lesson isn't about Flutter. It's that after the second wrong fix, the cheapest thing you can build is the thing that tells you what's happening.

Shipping it

Three surfaces means three kinds of release pain, and it was spread around. Hafeez and Harish were on every TestFlight build we cut, and Durga put the Android builds through the same. Karthi owns the Play Store side. Hafeez also took on Play's foreground-service declaration: Google won't let a calling app through until it has watched a video of a call ringing a locked phone, and that form is a small project of its own.

Take the build number from the store, not from your checkout. Your working copy doesn't know what was uploaded last week, and if two people build, it never will. Our lane asks App Store Connect and increments from the real answer.

A headless build that pipes to tail eats its own exit code. We had a fallback that was supposed to catch a failing build. It never fired, because the pipeline's status was tail's, and tail was very happy. The iOS build had been silently not happening.

And then Apple rejected us on Guideline 5 — Legal, for a reason I hadn't seen coming: China's Ministry of Industry and Information Technology asked that CallKit functionality be deactivated in apps on the China App Store. We use CallKit — that's what makes an iPhone ring like a phone for an incoming call — and China was listed as an available territory.

It was listed because we'd ticked every territory without thinking about any of them in particular. We were never going to sell in Madurai and Shanghai. Removing China mainland from availability didn't need a new build; Hong Kong, Macau and Taiwan are unaffected and the ring works there as normal.

One genuinely useful detail for anyone in the same position: after a rejection, the Resubmit to App Review button on the submission page stays greyed out. The path that works is Update Review on the version page, which flips the item back to Ready for Review and unlocks the resubmit.

Eight weeks of commits: 13, 29, 30, 37, 20, 3, 17 and 5, totalling 154. The busiest week, from 17 August, has 37 commits; the quiet week has three.
154 commits over eight weeks. The busiest, from 17 August, was rings, pins, mentions and the dead click. The quiet week is real — everyone on this has other work.

What it cost, and what is still wrong

I'd rather write this section than the usual one.

One server is one server. Everything in that first diagram runs on a single machine. If it goes, everything goes. We know. It's the correct trade for a team our size and it's still a single point of failure, not a design.

Deleting a message doesn't delete the image. Redacting an event removes the message; the uploaded bytes outlive it in media storage. Attachment content leaving the server on a timer is a separate, scoped piece of work we've written down and not yet done — and I'd rather say so than let the product imply something stronger than it does.

The documentation lags the code, consistently, and every time we speed up it gets worse.

Deliberately not done: federation (we're not talking to other homeservers yet, and turning it on later is easier than turning it off), end-to-end encryption on call media, and Synapse workers. Each is real work with no user today asking for it.

What we're publishing

Most of this is going on GitHub, because the parts that were hard were hard in ways that aren't specific to us, and the notes above are worth more in public than in our repo.

Published. The Flutter client. The Compose stack for Synapse, PostgreSQL and Caddy. The push gateway extensions. The guest token service. The admin service for creating accounts and resetting passwords. And the operations documentation — including the traps, which are the useful part.

Kept back. The call recording pipeline and the entitlement layer behind our paid tier. Those are how this pays for itself. The entitlement code in particular is the part where publishing a bug is somebody else's free lunch, and I'm not that committed to openness.

Secrets were kept out of the tree by design. Server configuration is rendered at deploy time from values that live on the machine, and signing keys are generated by Synapse into its own volume. If you clone it you get a stack with no credentials in it, which is the only kind worth publishing.


The six of us have been on it since August. Our conversations sit on a machine we control, a client joins a call from a link without installing anything, and when something breaks, it breaks in code we can read.

That last part is the whole return on seven weeks.

Let’s Build the Future Together.