How we built sync in Katara

Hiring is a team activity done from different places. A founder in London, a hiring manager in Berlin and an interviewer in Lisbon can all have the same pipeline open at once. If one of them moves a candidate to the next stage, the other two should see it happen. If one of them is on a train with patchy signal, their changes should still land when the signal comes back.

Most hiring tools treat this as a refresh problem. You make a change, someone else reloads and eventually everyone agrees. That works until two people act on stale information, a candidate gets two emails or a reply sits unseen because a tab was open from the morning. We wanted Katara to feel live by default, without turning a server-rendered app into something it isn't.

This post covers how we approached this problem, what we chose not to build, and where the tradeoffs landed.

Not a local-first database

The first decision was what not to do. Products like Linear keep a full copy of your workspace in the browser and sync every change through it. It's the right model for a tool people live in all day, but it comes with real cost: a client-side data model that mirrors the server, migrations in two places, and a long tail of edge cases when the two disagree.

Katara is a Rails app with server-rendered pages, and most of what people do is read a pipeline, act on a candidate, and move on. So we kept the server as the source of truth and built sync around the two places where liveness matters most: the pipeline board, and conversations. Everything else loads the normal way, with a small cache to make it feel instant.

Those two places turned out to have different shapes, and we stopped trying to solve them the same way.

A log for the pipeline

Pipeline data is shared. Many people read and write it, and the order of changes matters. Moving a candidate to Interview and then to Offer is not the same as the reverse.

We record every committed change to the pipeline as an entry in an append-only log, numbered by a single counter that only ever goes up. That number is the heart of the system. A browser's last seen number is its version of the team's data, and catching up after any disconnect becomes one simple question: what happened after this number?

Changes are recorded after commit. An entry is only written once the database transaction has succeeded, so nobody ever sees a change that later rolls back. Each entry carries the full record as it now looks, serialised the same way our pages already render it, so a card that arrives over sync is identical to one that arrived with the page.

Changes are filtered on the server. Not everyone on a team sees everything. An interviewer sees only the roles they interview for, and a hiring manager sees only their own. Every entry is checked against the viewer's role before it leaves the server, so a browser never receives something it shouldn't show.

Applying a change twice is harmless. Entries arrive over a websocket in real time, and again through a catch-up request whenever a connection is re-established. The browser ignores anything with a number it has already seen, which means the two paths never fight each other.

Too far behind means start again. We keep the log for seven days. A browser that has been away longer than that doesn't try to reconstruct what it missed; it discards its local state and loads the page fresh. Patching a large gap is where subtle bugs live, and a clean reload is a better outcome than a board that's quietly wrong.

Optimistic, but honest

Reading is half the problem. When you drag a card, it should move immediately, not after a round trip to the server.

Every change you make goes into a queue in the browser. The UI updates straight away, changes made together are batched, and the queue sends them to the server one at a time, in the order you made them. Sending sequentially is slower in theory, but it means the server always sees your intent in order, which matters more for a pipeline than raw throughput.

The queue is saved locally, so closing the tab doesn't lose your work. If the network fails or the server is busy, the change goes back to the front of the queue and is retried, and the board tells you you're offline. If the server rejects a change outright, we drop it and tell you why rather than retrying something that will never succeed.

The hardest part of optimistic UI is the moment after the server agrees with you. A change is accepted, then an older update from a teammate arrives a beat later with the previous state, and the card jumps back. To prevent that, every accepted change tells the browser which log number it corresponds to. Until the browser has caught up to that number, your version wins. Once it has, the server's version takes over. The result is a board that never flickers backwards, even with several people moving cards at once.

A cursor for conversations

Conversations are a different shape. They belong to a person rather than a team, they need to arrive instantly, and you should be able to write a reply with no signal and have it send later.

A shared log is the wrong fit here, so each browser instead keeps a cursor: the time of the most recent conversation change it has seen. When it reconnects, it asks for everything that changed since then and merges the result into a local copy of the inbox. New messages still arrive in real time; the cursor only fills in what was missed.

The inbox opens from local storage. On load we show the cached inbox immediately, then pull fresh data in the background. We pull again when the connection returns, and do a full reconcile when the tab comes back into view, which also removes conversations you no longer have access to.

Sending goes through an outbox. A message appears in the thread as soon as you hit send, and is delivered in order once the connection allows. Edits, deletes and read receipts take the same path. While something is still waiting in the outbox, your local version wins over whatever the server sends back, so an edit doesn't flash back to the original while it's in flight.

Making everything else feel fast

Most pages in Katara aren't live, and don't need to be. But they should still feel instant when you come back to them.

For those, we cache the data each page last received, keyed by the page and its filters, and scoped to the person who is signed in. Navigating back renders the cached version immediately while fresh data loads underneath it. If someone else signs in on the same browser, the previous person's cache is cleared.

It's a small piece, but it closes most of the gap between a server-rendered app and one that keeps everything locally, at a fraction of the complexity.

Small on purpose

Every part of this is a choice and some of them cost something.

Keeping the server as the source of truth means Katara isn't fully usable offline. You can queue changes and write messages but can't browse a pipeline you've never opened. Sending changes one at a time means a burst of edits on a slow connection takes a little longer to settle. Reloading when a browser falls too far behind means the rare person returning after a long break sees a full page load instead of a smooth update.

We think each of those is the right call for a hiring tool today. They keep the system small enough that a team our size can reason about all of it.