All posts
TechnologyDecember 22, 2025

Offline-First Landscape

Isaac Hinman

Introduction

When we set out to build Marco, we knew we were committing to two very difficult requirements:

  1. IMAP-based, not API-based
  2. Cross-platform – web, macOS, iOS – possibly more

We had a handful of additional ancillary requirements. One of these was offline-first, and I can now say confidently that we drastically underestimated its complexity.

I mentioned in a previous blog post that Missive was my daily driver in the recent past. It lacks offline support though, and this is one of the major downfalls of the product.

At Marco we believe that full offline support is crucial. "Managing your emails on an airplane with no wifi" is an example use case we frequently come back to. You should be able to read, delete, respond, and organise your emails with no internet connection. When you land and connect to wifi, everything should seamlessly sync.

That said, Marco is not a simple todo app. Marco is not an application that starts with zero data and grows in size gradually, as is the case with user-generated content like Notion, etc.

Marco is an application that deals with hundreds of MB of data, and hundreds of thousands (or millions) of rows/entities.

Essentially this means we are instantly jumping into the top 1% of heavy-duty use cases for offline-first implementations. Over time we realised that this actually rules out almost all available offline-first options.

Starting Point: WatermelonDB

I spent about a week deeply investigating the offline-first options available to us, in August 2025.

We (perhaps naively) had committed to the idea that our offline-first architecture should be database-agnostic – the offline-first logic should "end" at the API layer. We did not want to manage sync tables or schemas in Postgres – we wanted to write API endpoints, and manage our database ourselves.

Here's a rundown of the initial offline-first options we looked at:

  • WatermelonDB
    • FOSS, self-hosted
    • Database agnostic
    • Been around for ages, used in many production applications
  • PowerSync
    • Not quite FOSS, but has a free Self-Hosted Edition
    • Requires Postgres-level integration
    • Very complex architecture, requires both changes to Postgres and a separate HA MongoDB deployment cluster
    • Appears to have been around quite awhile, but all their case studies are on demo/tiny/side projects
  • ElectricSQL
    • Looked interesting, but was in the middle of a complete rewrite
    • Requires Postgres-level integration
    • New version only handles data sync one way – it does not handle mutations

There are many other options, including RxDB, MongoDB Atlas, Couchbase, and on and on. The three listed above are the options that we deeply investigated. As will become clear, we should have looked further at this stage.

We settled on WatermelonDB and built the initial alpha version of Marco on it. The backend implementation is rather simple: there is a "pull" endpoint to GET data, and a "push" endpoint to POST mutations.

It is important here to note that although Marco is a native application in some targets, it also must run in a web browser. While we may have access to a filesystem or "true" SQLite in native targets, our common denominator is web, where persistent storage options are very limited.

On the (web) frontend, Watermelon uses IndexedDB (as do essentially all other options – even the WASM SQLite options are usually SQLite-on-top-of-IndexedDB). However, it turns out Watermelon faces a serious problem that all other relational frontend databases face – IndexedDB performance is terrible. To solve this, Watermelon uses a LokiJS adapter, which is literally just an in-memory database.

Yes, you heard that right. To get around IndexedDB performance issues, Watermelon uses LokiJS to... hold the entire database in memory. When your database size is 100MB+, this starts to become a serious problem.

Moreover, clients must pull data before they can push any new mutations, and mutations can easily be clobbered if the row has been updated on the backend before the frontend can push mutations.

On top of this, WatermelonDB is not as actively-maintained as it once was. Many issues and PRs are left without a response. For example, chunked initial syncing is not supported out of the box. We opened a PR for this in early December, but it's still not been merged.

We got quite far along with the Marco alpha build, and then had a bit of a panic in November. Our confidence in our WatermelonDB-based offline-first approach was decreasing steadily. We began to seriously question if this technology could actually support a rock-solid, modern user experience.

We decided we needed to find something better.

New Wave of Offline-First

This time around, we threw out any preconceptions we had about Postgres, separation of concerns at the API layer, etc. We had completely open minds and desperately wanted to find the "best" solution, no matter what that might look like. We were now extremely clear on the fact that we had a "tough" offline-first use case, and needed some serious help.

We discovered a host of "new wave" offline first implementations. We talked with the founders/developers of these projects and found so many extremely intelligent and talented people working on what is a very tough problem.

The leaders in this new wave are:

  1. Triplit
  2. InstantDB
  3. Convex

While Triplit and InstantDB can be described as "full stack databases", Convex is instead an entire backend solution, including API endpoints, etc. For this reason we excluded Convex, as it seems like a huge leap and essentially 100% lock-in.

Problems with Triplit

Our first (of several) rewrites was from WatermelonDB to Triplit. Both Triplit and InstantDB use triples to represent data – hence the name. Triplit is built in TypeScript (both the client and the server) and has an impressively good developer experience. Their documentation is not perfect, but the team is extremely supportive and responsive.

Triplit's API and DX is best-in-class. However, although we desperately wanted to love the product, we found it unusable for our use case. With less than 100,000 entities in the database, subscriptions would time out, fail, and become out of date. We even crashed the Triplit server several times.

We believe Triplit is a fantastic choice for any offline-first applications with relatively small storage needs. On top of that, we believe there is a very real possibility that Triplit will become a strong option for heavier use cases like ours in the future. Their team is clearly very talented and moving fast.

We absolutely love the developer experience with Triplit, and are rooting for the team to succeed!

But we need something which is reliable, highly performant, and battle-tested, now.

Problems with InstantDB

We next moved onto InstantDB, which can be considered a direct competitor to Triplit. Although both InstantDB and Triplit use triples, they share very little else in common. InstantDB is backed by YC and has more funding, and the backend is built in Clojure.

TypeScript types were non-existent. There was no sort/ordering by fields. There was no support for $like operators, and we couldn't efficiently search through our data on the client side. From a DX perspective, Triplit is much further along.

On the backend side of things, there are no webhooks, so it is impossible to respond to mutations in a scalable way. We had to resort to polling for changes, which is not ideal.

Even looking past all of this, frontend queries that returned in 2-5ms with Watermelon+LokiJS were taking 200-500ms to return with InstantDB. This makes sense – an in-memory database will always be faster to query. But a 100x difference is a lot to sacrifice.

InstantDB is another promising product, and our understanding is that some teams are already building production applications with it. It's just not ready for our use case yet.

Problems with PowerSync

Finally, to our great disappointment, we begrudgingly moved on to PowerSync. We were wary of PowerSync from the beginning – the integration is complex and requires Postgres-level changes.

Although PowerSync is undoubtedly a mature product, and probably the most capable (on paper) of all options mentioned thus far, it has its own set of issues. The self-hosted setup is extremely complex. Deploying PowerSync's self-hosted solution requires a MongoDB replica set cluster. Combined with the write-ahead log (WAL) based integration with Postgres, this architecture introduces significant infrastructure complexity.

There is a paid SaaS offering, but their pricing model would have made our use case prohibitively expensive. Therefore we pursued the self-hosted option.

On the frontend side of things, PowerSync runs SQLite in WASM, and although the DX is fairly good, we found horrifying performance problems. PowerSync uses a shared worker that serialises and deserialises data between the main thread and the worker thread as JSON. This means every query result is JSON.parse()'d. Rendering a list of 50 emails took 200ms+.

Why So Many Problems?

There is a saying: if everyone is an asshole, maybe you're the asshole. Is the problem 5+ offline-first tools, or is it us?

Like with most things, the reality is "a bit of both". As mentioned, Marco is an incredibly data-intensive application, and we deal with data volumes that most frontend applications don't come close to.

However, we also found the practical limits of these tools to be way lower than one would expect.

What is the underlying cause? In my estimation, the root cause is that all of these offline-first tools for web are essentially hacks on top of the browser's persistency layer: IndexedDB.

All attempts to implement relational or graph databases within a web browser are essentially hacks. PowerSync itself is literally SQLite-in-WASM-on-top-of-IndexedDB. And the OG Watermelon approach is literally an in-memory JavaScript database.

There are essentially three different variables:

  1. The underlying (true) data store – this will always be IndexedDB for web implementations
  2. How the data is represented for sync purposes
  3. How the data is presented to developers

The new wave of tools are attempting triples/graph implementations, but the story is the same. Browsers only give you a key-value store (IndexedDB), and anything you build on top of that is going to have serious performance implications at scale.

To be absolutely clear: these relational and graph implementations on top of IndexedDB do not start to show their cracks when you have 10 rows. They start to show cracks at around 10,000-50,000 rows. That is where performance starts to degrade noticeably. And Marco has millions of rows.

At this point, we were starting to pull our hair out, and were wondering if we needed to build our own sync engine. Like everyone else, we'd build on top of IndexedDB, except we wouldn't try to put a relational layer on top. We'd just use the KV store directly.

We're only a team of two, and we have a lot to work on besides offline-first itself.

Finally, a Solution

Some time in early December, I came back across an option which I had glanced over before, but disregarded: Replicache.

I think we were initially put off by their strange pricing model and the fact that it's closed-source.

I am so glad we took another look.

In terms of backend implementation, Replicache is somewhat similar to WatermelonDB, in that you need to implement push and pull endpoints. However, Replicache uses a much more robust syncing protocol with versioned mutations, and the server-side implementation is well-documented.

The frontend is where the crucial difference lies – Replicache is just a KV store. It is a thin layer on top of IndexedDB that doesn't try to be clever. No relational model, no graph model, no SQL. Just keys and values. And it's blazing fast.

The drawback to this KV approach is that searching/sorting/ordering entities would require scanning through entire collections. This is where we introduced a secondary index layer on the frontend, which gives us the query performance we need without the overhead of a full relational database.

We'll post more detailed write-ups on our tech stack in the future, but a quick summary of where we landed on the frontend: Replicache for sync and persistence, with a custom in-memory index layer for efficient queries.

At the time of writing (January 2025), the Replicache team have just made it completely free and open source. This is because they are focusing their efforts on Zero, which is their next-generation product. Zero is essentially "Replicache, but fully managed" – similar to Triplit/InstantDB, but backed by a battle-tested sync engine.

We're eager to try Zero once it's a bit more stable, but for now will build our product on the extremely capable and robust Replicache.

The Future of Offline-First

We embarked on a long and rambling journey through essentially all prior art and work in the offline-first world. It's a fascinating space with incredibly talented people working on incredibly hard problems.

The good news is that there are many new teams and projects actively and energetically working on this problem.

Imagine a world where, as a fullstack developer, you can read and write data from an SDK in both your backend and your frontend – with type safety, real-time sync, and offline support – without having to think about sync protocols, conflict resolution, or persistence layers.

This is already possible today with Triplit or InstantDB, if your use case is reasonable. And things are only improving.

I believe 2025 will be a year where HTTP/REST APIs will start to feel antiquated. Don't share endpoints – share databases.