Inside Jottii — How We Built Cross-Device Sync Without Reading a Word
Inside Jottii — How We Built Cross-Device Sync Without Reading a Word
Cross-device sync is the feature most people want from a journal app. It's also the feature that puts the most pressure on a privacy promise. Once your data needs to move between devices, you have to handle it in transit, store it somewhere reachable, and reconcile concurrent edits — all without giving the server any reason to peek.
Here's how we built sync at Jottii. The short version: the server treats every entry as an opaque blob, and the work happens at the device boundary. The longer version is about the small decisions that protect that property.
The constraint
The single rule that shaped the sync system: the server must never see plaintext, ever. Not during a write, not during a read, not in a backup, not in a log line. If we couldn't satisfy this rule, we'd ship the feature differently.
This rule kills a lot of "easy" sync architectures. We can't store a JSON document of the entry in Postgres and let the database do search. We can't run server-side merge logic that needs to read content. We can't do server-side full-text search at all.
What we can do is store ciphertext, store metadata, and use a real-time channel to notify devices when ciphertext changes. The clients do the actual work.
The data model
The server has a single table, encrypted_entries, with roughly these columns:
id— UUID for the entry.user_id— owner.entry_date— the date the entry belongs to (for journal mode).ciphertext— the encrypted blob.nonce— the per-entry nonce forsecretbox.created_at,updated_at— timestamps.
That's most of it. There's no title, no content, no summary, no word_count, no tags. Everything content-related lives inside the encrypted blob and is parsed only on a device after decryption.
This means the server can answer "give me all entries this user has changed since timestamp X," which is enough for sync, and can't answer anything else useful about the user's content.
The keys
Each user has a master key generated on their first device. It's:
- 32 random bytes from
tweetnacl.randomBytes. - Stored in iOS Keychain or Android Keystore via expo-secure-store.
- Never transmitted to our server.
For the user to set up a second device, they enter a recovery phrase — a base64-encoded copy of the same master key — that they were given at signup. The phrase is the only thing that connects two devices. If they lose it, we can't help.
Encryption is NaCl secretbox (XSalsa20-Poly1305), authenticated symmetric encryption. Each entry gets a fresh random 24-byte nonce. (For why we chose NaCl, see Why We Chose NaCl Over WebCrypto.)
The write path
When you type into Jottii on, say, your phone:
- The app updates the local SQLite store immediately. Your screen shows the change instantly. The server has not been touched.
- A debounced background task (a few seconds after you stop typing) takes the latest state of the entry, encodes it as JSON, encrypts it with
secretboxand a fresh nonce, andupsertsto Postgres. - Postgres broadcasts a change event over Supabase Realtime to the channel for this user.
The web app and tablet, listening on the same channel, receive the change event. The event tells them the entry ID and timestamp; the actual ciphertext follows in a fetch. They decrypt with the local master key and update their local SQLite. Their UIs reflect the change typically within a second of you stopping typing.
The read path
When you open Jottii on a device:
- It reads from the local SQLite store and renders. No network needed.
- In the background, it asks the server "any entries changed since timestamp X?" where X is the last sync clock.
- The server returns ciphertexts for changed entries. The device decrypts them and updates the local store. The UI quietly reflects any incoming changes.
The first read after a long offline gap is a single API call. After that, real-time pushes do the work.
Conflict resolution
Conflict is the part that broke our naive first design. Two devices, both offline, both editing the same entry. What happens when they reconcile?
We tried "last writer wins" briefly. It silently lost work. We replaced it with a small per-field merge: each entry, after decryption, is a structured document (title, body, metadata). On conflict, the device merges field-by-field with explicit rules — body takes the longer version when timestamps are within a few seconds, otherwise newer; title takes newer; tag set unions.
The merge happens on the device, after decryption. The server never sees either version's content. It only sees that two ciphertexts arrived, the later one won, and a conflict event was logged for telemetry (entry ID, timestamps — no content).
For the rare cases where automatic merge could lose meaningful work, the device shows a small banner: "Two versions of this entry were detected. Tap to review." (We're working on making this rarer.)
Realtime
Supabase Realtime gives us per-row change events over a WebSocket. The events themselves carry the row, which means they carry ciphertext. Realtime pushes the ciphertext, the listening device decrypts.
The reason this works for privacy: the ciphertext is meaningless to anyone in the path. Supabase, our own logs, any intermediate proxy — they all see opaque bytes.
What the server learns anyway
We're honest about this: total zero-knowledge isn't physically achievable. The server learns:
- That a given user account exists.
- That certain entry IDs exist for that user.
- The dates of journal entries (we need this to sync efficiently and to render the calendar).
- Roughly when entries are written or modified (timestamps).
- That the user authenticated from some IP.
That's the metadata floor. We minimize it where we can — for instance, IPs aren't retained in long-term logs — and we document it. The content of every entry is invisible to us and stays that way.
Why we built it this way
We could have shipped a simpler sync model. Plaintext-on-the-server, with at-rest encryption, would have been a sprint instead of a quarter. We could have shipped server-side search. We could have shipped AI summaries.
We didn't, because the moment we ship any of those, the privacy property changes from "we can't read your data" to "we choose not to." Those are different promises and users can't see the difference from the outside.
The architecture is the proof. Cross-device sync, in real time, with no plaintext leaving the device. It's harder to build and we think it's the only honest way to ship a journal in 2026.
Try Jottii, and if you want to dig deeper into the model, the E2EE primer and the zero-knowledge architecture explainer are the next stops.