Fatih Işık ·

Moving Retail POS to an Offline-First Architecture: Why and How

Introduction

It all started with performance complaints from stores during busy campaign periods. Our first reflex was to look at our code: we integrated Application Insights into our microservices and web application, found the bottlenecks and made serious optimizations on the code side. But the complaints didn’t stop.

We decided to dig deeper into the data. When we grouped the statistics by register, store and transaction type, an interesting picture emerged: the problem wasn’t our code, it was the “road” it was running on.

The Problem: Heterogeneous and Unreliable Networks

Our stores had a wide and fluctuating mix of connectivity, from fiber and VDSL to ADSL and backup mobile internet (4G). No matter how good our code was, we kept hitting the limits of the physical line.

Especially on campaign days, with traffic increasing, stores with low bandwidth experienced packet loss and high latency. For the customer waiting at the checkout this meant “the system is frozen”, and for the cashier it meant stress. We realized we couldn’t solve this on the server side (backend); we had to solve it at the source, at the register (the edge).

Searching for a Solution and Making the Call

Once we had identified the problem, we started brainstorming around one question: “What can we do?” Three main ideas were on the table:

  1. Writing a Separate Offline Application: It looked like the easiest option, but managing two different codebases (online and offline) and keeping business rules in sync would have been a maintainability nightmare. We ruled it out.
  2. Moving the Microservices onto the Registers (Docker): We considered running our existing microservices in Docker containers on the registers. But the hardware constraints across thousands of registers and the difficulty of orchestrating such a distributed system scared us off. It would have changed the shape of the problem instead of solving it.
  3. Local Replica Server (The Winning Idea): This idea, which I championed, was based on the principle of “separating business logic from data”. If we could abstract the business logic, the same code could talk to SQL Server in the cloud and to SQLite on the register.

We made our decision: we would not write a new application. We would evolve our existing architecture into one that is independent of its data source and hosts a lightweight “Replica API” locally.

Technology Choice: Why SQLite?

We had no constraints on language and framework; our microservices were already written in .NET, so there was no need to change ecosystems. The real question was: Where do we keep the data?

The database we were looking for had to be embedded. Installing a separate SQL Server on thousands of registers would have been operational suicide. With that criterion, we evaluated alternatives such as Couchbase Lite, LiteDB and SQLite.

At first Couchbase Lite was very attractive, since we were already using Couchbase centrally. But reality in the field hit us: some older register hardware ran on 32-bit architecture, and Couchbase Lite had no 32-bit support. That pushed us toward a leaner, more universal solution that runs everywhere: SQLite.

However, SQLite is a relational database, while our central system was NoSQL. We didn’t want to run schema migrations on thousands of registers with every new feature. This is exactly where SQLite’s JSON support came to the rescue.

We asked ourselves, “Why not use SQLite like a NoSQL database?” We stored the data as JSON and created indexes through generated columns for the fields we needed to query. This let us keep our schema independence (schema-less) while benefiting from SQLite’s speed and reliability. We started development by building a generic collection structure.

Architecture Detail: Abstracting the Business Logic

Our biggest fear was code duplication. We didn’t want to write the same sales rules (discounts, campaigns, taxes, etc.) over and over, both in the central microservices and in the offline application on the register.

As the solution, we put the Repository Pattern at the center. We fully isolated the business layer from where the data comes from (SQL Server, Couchbase or SQLite). The workflow simply said “get me this product”; it didn’t care which database was queried behind the scenes. This let us turn the business logic into a shared NuGet package and achieve 100% code sharing on both sides.

Data Synchronization: The Power of NATS JetStream

Getting data down to the registers (sync down) was the hardest part. Thousands of registers constantly asking the center “Is there a change?” (polling) would have brought our servers to their knees.

Here we built an event-driven architecture using NATS JetStream. When a product price changes centrally, an event is published to the relevant registers via JetStream.

A background worker running in the Offline Server on the register listens to these messages and updates the local SQLite database. Thanks to JetStream’s durable consumers, messages are not lost even if the register is turned off or offline at that moment; as soon as the register comes back up, it processes the queued messages and catches up.

Data Flow from Register to Center (Sync Up)

Sending completed sales to the center was just as critical as getting data down. Here we used the Quartz.NET library to define collection-based jobs.

When a sale is completed, the data is first written to the safe harbor: the local SQLite database. Right after that, a job triggered in the background picks up the data and forwards it to the center. If there is no internet connection at that moment, the job waits for its next retry. This gave us an asynchronous flow that moves data to the center almost in real time, without making the customer wait at the moment of sale.

Of course, we also built a fallback mechanism just in case: on every start-up, the Offline Server checks the critical master data and fills in anything missing.

Results and Takeaways

After this architectural transformation, the “the system froze” complaints from stores stopped completely. Checkout operations dropped to the millisecond level, regardless of internet speed.

But our biggest gain wasn’t just speed; we gained flexibility. The offline-first foundation we built is now the backbone of the Self-Checkout project we are currently developing. We can offer customers a fast, smooth payment experience that is unaffected by network outages.

As a technical lead, the biggest lesson I took from this process was this: sometimes the best solution isn’t to use the newest technology (Docker, etc.), but to build the architecture that solves the problem at hand in the simplest and most robust way (Local Replica).