Dock Dock All notes

What local-first actually costs

Someone opened Dock, saw You're all caught up, and closed it again.

They were not all caught up. There were messages waiting. The query that should have fetched them had failed, and the screen we show when there is nothing to read and the screen we show when the read itself died were the same screen.

That bug is a good way into the subject, because it cannot happen in a normal app. A normal app is waiting on a server. When the server does not answer there is a spinner, then an error, then a button that says try again. The failure has somewhere to live. Take the network out of the middle and it has nowhere to go. Data is either in the local database or it is not, and none, not yet and something broke all render as the same quiet, well-designed nothing.

This is a note about what that costs. Not the sales version, where offline support is a checkbox, but the bill we actually paid.

What we thought we were buying

The pitch we made to ourselves was simple enough. Your messages live on your own device, in a real database. Opening a channel is a local read. Search is a local query. Scrolling back through a year of history does not ask anyone's permission. On a train with no signal you can read everything, write replies, and they go out when you surface.

All of that is true. We built it, it works, and it is quick in a way people notice inside ten seconds. That part of the bet paid.

You lose the server as referee

In an ordinary chat app the server settles every argument about order. Two people send a message, the server stamps them as they arrive, and that is the order. Nobody thinks about it because there is nothing to think about.

Offline breaks that. Someone writes three messages on a flight while someone else is at their desk. When the plane lands, what order is everything in? Compose time is tempting and useless, because it comes from a clock you do not control and cannot verify. We have all seen the phone that is nine minutes fast.

What we settled on is that the server stamps a message when it receives it, not when it was written. It gives everyone a single order they agree on, and it makes conflicts resolvable without ceremony.

It also costs something real. The messages you wrote at altitude land where they arrived, not where you wrote them. A reply you typed before takeoff can appear after three messages written later than yours but uploaded sooner. The timestamp on your own message is, in a small way, not true. We chose this knowing that, because every alternative we looked at was worse, but it is a thing we gave up rather than a thing we got for free.

Your schema changes shape

Reactions look trivial. The obvious design is a column on the message holding a little bundle of who reacted with what.

That survives until two people react while both are offline. Each client has its own copy of the bundle, each pushes the whole thing, and the second one to arrive overwrites the first. One person's reaction disappears. No error, no conflict, no way to notice.

So reactions are rows. One row per person per emoji, with a uniqueness constraint doing the work. Two offline reactions become two inserts that merge without either client knowing the other existed. It is a duller schema with more rows in it, and it is the only version that survives contact with real people.

That shape repeats everywhere. Anything two disconnected people can touch has to be built so their changes combine rather than overwrite, and you find out which things those are by getting it wrong first.

Delete does not mean delete

We deleted a message locally once, the way anyone would. Remove the row.

It came back. Sync compared the two sides, saw a row on the server that was missing on the client, concluded the client was behind, and helpfully put it back. The message vanished and then returned about a second later, which is a genuinely unsettling thing to watch.

Deletion is now a change of status rather than a removal. Mark it deleted, let that travel, render it as gone, never take the local row out. Archiving a channel works the same way. The rule underneath is that the local database is not yours to edit freely. It is a replica with opinions.

Some things should not sync at all

Presence, the little green dot, is the clearest example. It changes constantly, it is worthless thirty seconds later, and it is exactly the wrong shape for a system whose entire purpose is to eventually deliver everything. Putting it through the sync engine would mean spending the machinery built for "never lose a message" on something we are happy to lose.

So presence goes over plain HTTP, out of band, best effort. Offline, you see stale dots. That is a fine trade, because a stale dot is not a lost message.

Attachments split the same way. The record of a file syncs; the bytes do not. An early version of that had a bug worth repeating: it cached the result of a failed fetch. Try to load an image with no signal, get nothing back, store the nothing. From then on the image is permanently blank even once you reconnect, because as far as the client is concerned it already has it. A failed fetch has to be forgotten, not remembered.

The things you assumed came free

Search, for instance. SQLite has full-text search built in and it is excellent, but the tables the sync engine manages are not yours to reshape. So you build the search index alongside them, with triggers to keep it fed, and you do it on every client separately.

That is the pattern for a lot of this work. Things that are one line on a server become a small project on the device, and then you do that project again for the next platform.

Everything, three times

Dock runs on Mac, Windows, iPhone and Android, which in practice means three codebases in three languages with three different sync SDKs and three sets of platform habits. Every decision above gets made once and implemented three times, and the three implementations drift apart while you are not looking.

The expensive bugs were almost never in the sync engine. They were in the seams. A view that renders before the database has finished opening. A query that re-runs because a value changed identity without changing content. A screen that shows the previous conversation's messages for a beat before the right ones arrive. You scroll someone to a search result and then sync delivers a handful of older rows underneath it, and the thing they were looking at slides quietly off the screen.

None of those are hard problems. There are just a lot of them, and each one is invisible until someone with a bad connection finds it for you.

The one that cost the most and looked like nothing

Sync rules are written as SQL, and they are read by the sync service's own parser rather than by SQLite.

SELECT * FROM messages works SELECT m.* FROM messages m syncs nothing, forever

Those are the same query to every database in the world. The second one deploys cleanly, reports no error, passes validation, and then matches zero rows for the rest of time.

The client is not broken. The server is not broken. Nothing is logged anywhere, because from the system's point of view nothing went wrong. There is just an app with nothing in it.

In a request-and-response app, a mistake gives you a stack trace. Here, a mistake gives you an empty room.

That is the tax, stated as plainly as we can. Not the syncing, which is mostly a solved problem you can buy. The tax is that your failures stop announcing themselves, and you have to go looking for them on purpose, usually on a bad train connection, usually after a customer has already quietly decided the app is empty.

Was it worth paying

Two answers, and they point in different directions.

For the product, yes. It is genuinely fast. Everything opens immediately, search returns before you have finished typing, and nobody who has used it for a week wants to go back to watching a spinner to read a message they already read yesterday.

As a reason for anyone to switch, no. Not once, as far as we can tell. It is the most expensive thing we have ever built and it did not move a single team off the tool they were already using. That is the subject of the previous note, and it is why this one is written as an accounting rather than a boast.

We are keeping it anyway. Not because it sells, but because it is the right way to build this kind of software, and because everything we are redoing sits on top of it rather than underneath it. Paying a bill twice would be worse than having paid it once.

If you are weighing this up for your own product, the short version is that you are not adding offline support. You are taking the server out of the middle of every decision you have already made, and then making all of those decisions again, without it.