Skip to main content

Building an Offline-First Web App: Synchronizing RxDB with Legacy REST APIs via GraphQL Mesh

Delivering seamless user experiences in unreliable network conditions without rewriting your backend.

In today’s connected world, users expect web applications to be available and performant at all times, regardless of network stability. The dreaded “You are offline” message is rapidly becoming an unacceptable user experience, especially for critical business applications.

We recently encountered this challenge while working with a client whose field workers needed to input data, search records, and perform complex tasks in environments with highly unreliable, or often non-existent, network connectivity. Their requirement was clear: a true “Offline-First” application.

The significant constraint? The backend was a suite of existing, mature RESTful microservices—a robust system that could not be rewritten or significantly altered overnight.

This post details the architecture we designed to bridge the gap between a modern, reactive, offline-capable frontend and these established REST backends. Our strategic tools: RxDB on the client for local data management, and GraphQL Mesh acting as an intelligent synchronization gateway.

The Challenge: Bridging Sync and Legacy REST

Building an offline-first app is more than just caching a few API calls. It demands a fundamental architectural shift where the primary source of truth for the user interface is a local database residing directly in the browser.

Our solution needed to address several critical requirements:

  1. Local Persistence: Efficiently store large volumes of structured data within the browser (using IndexedDB).
  2. Reactive UI: Provide a reactive API to the frontend framework, ensuring the UI updates instantly whenever local data changes.
  3. Seamless Synchronization: Intelligently push local changes to the server when online and pull updates from the server, all while handling offline queues.
  4. Legacy Integration: Most importantly, connect robustly and reliably to existing REST APIs that were never designed for real-time, bidirectional synchronization.

RxDB (Reactive Database) was an obvious choice for requirements 1-3. Its powerful replication protocols are ideal for syncing with modern GraphQL endpoints that support cursor-based pagination and checkpoints. However, directly connecting RxDB to our client’s standard REST endpoints would have necessitated an enormous amount of custom, error-prone “glue code” on the client side to manage translation, state, and complex sync logic.

We needed an intelligent middleware layer to translate between RxDB’s sophisticated synchronization needs and the practical realities of the existing REST backend.

HLA

We resolved this impedance mismatch by introducing GraphQL Mesh as an API Gateway, strategically positioned between the frontend and the backend services.

This design allowed us to abstract away the REST complexities from the client and provide RxDB with a GraphQL endpoint that “speaks its language.

The Data Flow Breakdown:

  1. The UI Layer: The frontend application (built with frameworks like React, Vue, or Angular) never directly communicates with the network for data. It exclusively interacts with the local RxDB instance. The UI subscribes to queries on this local database, ensuring it remains instantly reactive and responsive, regardless of network status.
  2. RxDB (The Local Hero): RxDB handles all local data management within the browser’s IndexedDB. It’s responsible for queuing local changes when offline and initiating synchronization when a connection is restored. Critically, it uses its GraphQL Replication plugin to manage the bidirectional sync process.
  3. GraphQL Mesh (The Translator Gateway): This is the core innovation of our architecture. GraphQL Mesh consumes the OpenAPI/Swagger definitions of our client’s diverse backend REST Services. It automatically stitches these into a unified GraphQL schema, instantly exposing the REST endpoints as GraphQL queries and mutations.
  4. Custom Resolvers within Mesh: We implemented thin custom resolvers within GraphQL Mesh. These resolvers are designed to translate RxDB’s specific GraphQL replication queries (e.g., “give me all documents changed since checkpoint X”) into the appropriate REST API calls (e.g., GET /api/items?updated_after=X or PUT /api/items/{id}).
  5. REST Backend: The existing backend services remain the ultimate source of truth, operating as they always have, largely unaware that they are now part of a sophisticated offline-sync architecture.

Deep Dive: The Sync Strategy & Conflict Resolution

The most challenging aspect of offline-first application development is not merely storing data locally; it’s robustly and reliably synchronizing that data. When multiple users, or even the same user on different devices, make changes offline and then reconnect, the potential for data conflicts is high.

1. Choosing a Conflict Strategy: Last Write Wins (LWW)

For this particular project, we opted for a Last Write Wins (LWW) strategy, based on server-side timestamps.

Why LWW? We extensively evaluated Conflict-free Replicated Data Types (CRDTs). CRDTs are a powerful and elegant solution for highly collaborative offline applications (think of the real-time collaboration in Google Docs or Figma). They allow multiple users to edit the same document simultaneously without explicit conflicts by storing and merging deltas (individual changes) rather than relying on final states.

However, implementing CRDTs fundamentally requires the backend to be aware of and designed for the sync logic. This would have meant significant restructuring of the client’s existing SQL-based microservices, transforming them from storing final states to managing event logs. Given our strict constraint to use existing REST services without major backend rewrites, CRDTs were architecturally too intrusive and costly for this project.

How LWW Works in Our Setup: Our LWW strategy hinges on a server-maintained lastModifiedAt timestamp on every record.

  1. Client Push: When a client sends a mutation, its local lastModifiedAt is included in the payload.
  2. Server Decision: The server compares the incoming lastModifiedAt with the current lastModifiedAt in its database for that record.
  3. Conflict Resolution:
    • If the incoming data’s timestamp is newer, the server updates the record, overwriting the existing data.
    • If the incoming data’s timestamp is older (meaning the server already has a more recent version), the server either rejects the update or simply does nothing, signaling that the client’s version is stale.
    • The client is then expected to pull the server’s latest version during its next synchronization cycle.

2. The “Echo” Problem & Loop Avoidance

A critical and frequently overlooked challenge in bidirectional synchronization is the “Feedback Loop” or “Echo” problem:

  1. Client A updates a record locally and then pushes this change to the server.
  2. The Server successfully saves the change, updating its lastModifiedAt timestamp for that record.
  3. Client A then initiates a “pull” synchronization, asking for “all records changed since my last sync point.”
  4. The Server dutifully returns the record Client A just pushed, because it now has a new lastModifiedAt timestamp.
  5. Client A re-processes this data, potentially causing an unnecessary UI flicker, redundant re-renders, or even re-triggering business logic that should only run for external changes.

To effectively prevent this, we established a strict contract with the backend REST APIs.

The Backend Contract: Requirements for REST Services

While our mandate was to avoid rewriting the backend services, we did require that two specific metadata fields be included on every entity exposed via the REST API to ensure our RxDB replication protocol could function correctly. These are standard fields that most robust APIs should already consider.

1. lastModifiedAt (ISO Timestamp)

This field is the core of our synchronization mechanism.

  • Purpose: It allows the client (via GraphQL Mesh) to request “Delta Updates”—only those records that have changed since the client’s last synchronization checkpoint.
  • Implementation: The GraphQL Mesh resolver translates RxDB’s internal checkpoint into a query parameter for the REST API.
    • RxDB Internal Query: replicationPrimitive.pull.queryBuilder = (checkpoint) => { … }
    • Mesh Translation: GET /api/tickets?min_updated_at=2023-10-27T10:00:00Z
  • Backend Responsibility: The backend must ensure this timestamp is accurately updated on every successful write operation.

2. lastModifiedBy (User ID or Device ID)

This field was absolutely crucial for solving the “Echo” problem and preventing synchronization loops.

  • Purpose: To uniquely identify who (or what system) made the most recent change to a record.
  • The Logic: When RxDB pulls new data from the server (via Mesh), it inspects the lastModifiedBy field:
    • If lastModifiedBy === CurrentUserOrDeviceID, RxDB correctly assumes, “I already have this version; I was the one who pushed it.” It then intelligently drops this incoming data, preventing re-processing.
    • If lastModifiedBy === OtherUserOrSystemID, RxDB recognizes this as a genuine external change and applies the update to its local store.

Conceptual Code Logic (within RxDB Replication Config):

TypeScript

// Inside RxDB Replication Config for the 'pull' stream

pull: {

    async handler(lastCheckpoint) {

        // 1. Query GraphQL Mesh for updates since the last known checkpoint

        const response = await graphQLMeshClient.query(

            GetDocumentsChangedSinceQuery, 

            { minUpdatedAt: lastCheckpoint.updatedAt }

        );




        // 2. Filter out "Echoes" (our own changes) to prevent feedback loops

        const incomingDocs = response.data.items.filter(doc => {

            // Only process documents that were modified by ANOTHER user/system

            // 'myUserId' or 'myDeviceId' is a client-side identifier.

            return doc.lastModifiedBy !== myUserId; 

        });




        return {

            documents: incomingDocs,

            checkpoint: { updatedAt: new Date().toISOString() } // Update our client-side checkpoint

        };

    }

},

// ... (push configuration for sending local changes to Mesh)

 

The Unifying Role of GraphQL Mesh

The true elegance of this architecture lies in how GraphQL Mesh unifies the system. The backend REST APIs can remain simple, focusing on core CRUD operations and maintaining the two required metadata fields. RxDB on the client side handles its local database and reactive UI. It’s GraphQL Mesh that takes on the heavy lifting of translation and orchestration.

We leveraged Mesh’s powerful Transforms feature. Even if different microservices used inconsistent field names (e.g., one had updated_time, another last_modified_date), Mesh allowed us to map them all to a unified lastModifiedAt in the GraphQL schema. This ensures the frontend code remains clean, consistent, and oblivious to the underlying REST heterogeneity.

Conclusion

Modernizing and enhancing legacy systems doesn’t always necessitate a complete, costly backend rewrite. By strategically combining the client-side power of RxDB for robust offline data management with the flexible integration capabilities of GraphQL Mesh, we built a state-of-the-art, resilient offline-first application.

This approach delivered:

  • True Offline Capability: Users can work seamlessly for extended periods without an internet connection, with the app behaving identically online or offline.
  • Minimal Backend Impact: We avoided asking backend teams to significantly alter their existing, battle-tested REST APIs.
  • Accelerated Development: GraphQL Mesh dramatically reduced the need for custom frontend data fetching, mapping, and sync logic, saving substantial development time.
  • Blazing-Fast UI: The UI reads directly from the local browser database, virtually eliminating network latency from routine user interactions and providing an instant, delightful experience.

By accepting the “Last Write Wins” strategy (a pragmatic choice for many business applications) and mandating two well-defined metadata fields (lastModifiedAt, lastModifiedBy) on our REST endpoints, we successfully tackled common synchronization challenges like the “Echo” problem. This resulted in a robust and maintainable offline-first system that truly empowers users in any network condition.