DEV Community

Cover image for Building an Offline-First Flutter POS with NestJS and PostgreSQL
مصعب يوسف
مصعب يوسف

Posted on Originally published at ms3byoussef.medium.com on

Building an Offline-First Flutter POS with NestJS and PostgreSQL

Building an Offline-First Flutter POS with NestJS and PostgreSQL

How to build a point-of-sale application that works without internet, safely synchronizes sales, and prevents duplicate transactions.

Flutter · Dart · Drift · SQLite · NestJS · Prisma · PostgreSQL

Introduction

Imagine you’re building a point-of-sale (POS) application for a retail store.

Everything works perfectly until the internet connection goes down.

Suddenly, cashiers can’t complete sales, customers start waiting, and the business loses revenue because the application depends entirely on a remote server.

This is a common architectural mistake.

A reliable POS application shouldn’t require a constant internet connection to perform its most fundamental operation: recording a sale.

Instead, it should be able to:

  • Record sales locally, even without internet.
  • Preserve unsynchronized transactions when the application restarts.
  • Detect when synchronization can be attempted.
  • Automatically send pending transactions to the backend.
  • Prevent duplicate sales when network requests are retried.
  • Clearly distinguish locally recorded sales from server-confirmed sales.

In this tutorial, we’ll build the foundation of an offline-first POS using Flutter, NestJS, and PostgreSQL.

More importantly, we’ll explore the engineering decisions that make offline synchronization reliable.

Our goal is not simply to cache data. It’s to design a system that can recover from network failures without duplicating financial records.

1. Understanding Offline-First Architecture

A traditional mobile application typically follows this pattern:

Flutter App
    |
    v
REST API
    |
    v
PostgreSQL
Enter fullscreen mode Exit fullscreen mode

When the network fails, the application can no longer write data.

An offline-first architecture introduces local persistence and a dedicated synchronization process:

Flutter POS
           |
           v
     Sale Repository
           |
           v
       SQLite
           |
           v
      Pending Sales
           |
           v
       Sync Engine
           |
           v
       NestJS API
           |
           v
      PostgreSQL
Enter fullscreen mode Exit fullscreen mode

The important difference is that a sale is saved locally before attempting to send it to the server.

This is commonly called a local-first write strategy.

The Local Database Is the Immediate Source of Truth

The cashier interface reads sales from the device’s local database.

When a new sale is recorded, the application immediately displays it with a synchronization status.

For example:

SaleAmountStatusSALE-001$45.00SyncedSALE-002$18.50PendingSALE-003$72.00Pending

This allows the cashier to keep working without blocking the interface on network availability.

However, “saved locally” and “accepted by the backend” are two different states.

A production application must communicate this distinction clearly.

2. Technology Stack

We’ll use the following technologies:

LayerTechnologyClient applicationFlutterLocal databaseSQLite with DriftAPINestJSServer databasePostgreSQLORMPrisma 7CommunicationREST over HTTPSIdentityClient-generated UUIDsSynchronizationLocal pending queue and retry logic

I prefer Drift for this implementation because it provides a typed database layer, reactive queries, and cross-platform SQLite support.

The same architectural approach can support Android, iOS, Windows, macOS, and Linux.

For this tutorial, we’ll focus on offline cash-sale recording. Payment-terminal settlement, multi-device stock reservations, and fiscal compliance require additional integrations.

3. The Most Important Rule: Every Sale Needs a Stable ID

Before writing any code, let’s understand a failure scenario.

A cashier records a sale for $50.

The Flutter application sends the transaction to NestJS.

PostgreSQL successfully stores it.

But the connection drops before Flutter receives the response.

The mobile application doesn’t know whether the sale was saved.

If it creates a new sale every time it retries, the server may record the same transaction twice.

That’s unacceptable for financial software.

The Solution: Idempotency

Each sale receives a unique identifier generated once on the device.

For example:

clientSaleId:
f05e10b1-b1ae-4e68-a2e9-21bb66133b90
Enter fullscreen mode Exit fullscreen mode

Every retry of that same sale must reuse this identifier.

The backend then enforces a unique constraint on the combination of tenant and sale ID.

The result is straightforward:

  • First request: create the sale.
  • Repeated request with identical data: return the existing sale.
  • Repeated ID with different data: reject as a conflict.

This is an example of an idempotent API operation.

Our backend will enforce this rule at the database level, not just in application memory.

4. Building the NestJS Backend

Let’s start with the backend contract because the client needs to know what to synchronize.

We’ll assume an existing NestJS application with JWT authentication and an authenticated tenant context.

Install Prisma

npm install @prisma/client @prisma/adapter-pg pg
npm install class-validator class-transformer
npm install -D prisma @types/pg
Enter fullscreen mode Exit fullscreen mode
npx prisma init
Enter fullscreen mode Exit fullscreen mode

In Prisma 7, the generated client uses an explicit output directory and PostgreSQL requires a driver adapter.

Configure prisma/schema.prisma:

generator client {
  provider     = "prisma-client"
  output       = "../src/generated/prisma"
  moduleFormat = "cjs"
}
datasource db {
  provider = "postgresql"
}
model Sale {
  id           String   @id @default(uuid()) @db.Uuid
  clientSaleId String   @db.Uuid
  tenantId     String
  deviceId     String
  items        Json
  totalMinor   BigInt
  payloadHash  String
  createdAt    DateTime @default(now())
  @@unique([tenantId, clientSaleId])
  @@index([tenantId, createdAt])
}
Enter fullscreen mode Exit fullscreen mode

The @@unique constraint is critical.

It prevents the same tenant from creating multiple server records with the same client sale ID.

The payloadHash allows the backend to detect attempts to reuse an ID with different transaction details.

We store amounts in minor currency units rather than floating-point values.

For example, $19.99 is stored as 1999 cents.

This avoids introducing binary floating-point rounding into financial totals.

Configure your database URL in .env and load it through prisma.config.ts:

import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
  schema: 'prisma/schema.prisma',
  migrations: {
    path: 'prisma/migrations',
  },
  datasource: {
    url: env('DATABASE_URL'),
  },
});
Enter fullscreen mode Exit fullscreen mode

Generate the client and migrate the database:

npx prisma migrate dev --name offline_sales
npx prisma generate
Enter fullscreen mode Exit fullscreen mode

Create the Request DTO

Create create-sale.dto.ts:

import { Type } from 'class-transformer';
import {
  ArrayMaxSize,
  ArrayMinSize,
  IsArray,
  IsInt,
  IsString,
  IsUUID,
  Min,
  ValidateNested,
} from 'class-validator';
export class SaleItemDto {
  @IsString()
  productId!: string;
  @IsInt()
  @Min(1)
  quantity!: number;
  @IsInt()
  @Min(0)
  unitPriceMinor!: number;
}
export class CreateSaleDto {
  @IsUUID()
  clientSaleId!: string;
  @IsString()
  deviceId!: string;
  @IsArray()
  @ArrayMinSize(1)
  @ArrayMaxSize(100)
  @ValidateNested({ each: true })
  @Type(() => SaleItemDto)
  items!: SaleItemDto[];
}
Enter fullscreen mode Exit fullscreen mode

The device sends the immutable sale payload each time it retries.

Notice that we don’t accept a tenantId from the request body.

The backend must derive tenant identity from a verified, authorized session.

Implement an Idempotent Sales Service

Create sales.service.ts:

import {
  BadRequestException,
  ConflictException,
  Injectable,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { CreateSaleDto } from './create-sale.dto';
@Injectable()
export class SalesService {
  constructor(private readonly prisma: PrismaService) {}
  async syncSale(tenantId: string, dto: CreateSaleDto) {
    const items = dto.items.map((item) => ({
      productId: item.productId,
      quantity: item.quantity,
      unitPriceMinor: item.unitPriceMinor,
    }));
    const total = items.reduce(
      (sum, item) =>
        sum + item.quantity * item.unitPriceMinor,
      0,
    );
    if (!Number.isSafeInteger(total)) {
      throw new BadRequestException('Invalid total');
    }
    const hash = createHash('sha256')
      .update(JSON.stringify({
        deviceId: dto.deviceId,
        items,
      }))
      .digest('hex');
    const where = {
      tenantId_clientSaleId: {
        tenantId,
        clientSaleId: dto.clientSaleId,
      },
    };
    const existing = await this.prisma.sale.findUnique({
      where,
    });
    if (existing) {
      return this.replay(existing, hash);
    }
    try {
      const sale = await this.prisma.sale.create({
        data: {
          tenantId,
          clientSaleId: dto.clientSaleId,
          deviceId: dto.deviceId,
          items,
          totalMinor: BigInt(total),
          payloadHash: hash,
        },
      });
      return {
        serverSaleId: sale.id,
        status: 'accepted',
        replayed: false,
      };
    } catch (error) {
      // Handles concurrent requests racing to insert
      // the same clientSaleId.
      if (
        error !== null &&
        typeof error === 'object' &&
        'code' in error &&
        error.code === 'P2002'
      ) {
        const saved = await this.prisma.sale.findUnique({
          where,
        });
        if (saved) {
          return this.replay(saved, hash);
        }
      }
      throw error;
    }
  }
  private replay(
    sale: { id: string; payloadHash: string },
    hash: string,
  ) {
    if (sale.payloadHash !== hash) {
      throw new ConflictException(
        'Sale ID reused with different data',
      );
    }
    return {
      serverSaleId: sale.id,
      status: 'accepted',
      replayed: true,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The PrismaService here is your application's standard injectable Prisma 7 client, initialized with PrismaPg and the configured PostgreSQL connection.

Why check for P2002?

Because two identical requests can reach the backend concurrently.

Both might initially find no existing sale. But PostgreSQL’s unique constraint allows only one insert to succeed.

The other request can then retrieve the existing record and return a successful replay response.

This protects against a common race condition.

Important scope limitation: This example demonstrates durable, idempotent sale ingestion. It does not yet authorize product pricing or update inventory. A production POS must enforce an approved price-list policy, validate the device’s permissions, and process related financial or inventory changes atomically. Client-supplied prices should never be treated as trusted simply because they passed DTO validation.

Create the API Controller

import {
  Body,
  Controller,
  HttpCode,
  Post,
  Req,
  UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { SalesService } from './sales.service';
import { CreateSaleDto } from './create-sale.dto';
@Controller('sales')
@UseGuards(AuthGuard('jwt'))
export class SalesController {
  constructor(private readonly sales: SalesService) {}
  @Post('sync')
  @HttpCode(200)
  sync(@Req() req: any, @Body() dto: CreateSaleDto) {
    // tenantId must come from a verified JWT
    // and an authorized tenant membership.
    return this.sales.syncSale(
      req.user.tenantId,
      dto,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Enable a global ValidationPipe, register the service and controller, and configure the existing authentication guard.

The backend API is now capable of safely accepting repeated requests for the same sale.

5. Building the Flutter Local Database

Now let’s move to Flutter.

Install our dependencies:

flutter pub add drift drift_flutter http uuid
flutter pub add --dev drift_dev build_runner
Enter fullscreen mode Exit fullscreen mode

We’ll create a small local sales table.

Each sale has three possible synchronization states:

  • pending: Saved locally, waiting to synchronize.
  • synced: Confirmed by the backend.
  • needs_review: Rejected or conflicted, requiring attention.

Create lib/data/pos_database.dart:

import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:uuid/uuid.dart';
part 'pos_database.g.dart';
class LocalSales extends Table {
  TextColumn get id => text()();
  TextColumn get deviceId => text()();
  TextColumn get itemsJson => text()();
  TextColumn get state => text()
      .withDefault(const Constant('pending'))();
  TextColumn get errorMessage => text().nullable()();
  DateTimeColumn get createdAt =>
      dateTime().withDefault(currentDateAndTime)();
  @override
  Set<Column> get primaryKey => {id};
}
@DriftDatabase(tables: [LocalSales])
class PosDatabase extends _$PosDatabase {
  PosDatabase()
      : super(driftDatabase(name: 'offline_pos'));
  @override
  int get schemaVersion => 1;
  Future<String> saveSale({
    required String deviceId,
    required List<Map<String, dynamic>> items,
  }) async {
    final saleId = const Uuid().v4();
    await into(localSales).insert(
      LocalSalesCompanion.insert(
        id: saleId,
        deviceId: deviceId,
        itemsJson: jsonEncode(items),
      ),
    );
    return saleId;
  }
  Future<List<LocalSale>> pendingSales() {
    return (select(localSales)
          ..where((s) => s.state.equals('pending'))
          ..orderBy([
            (s) => OrderingTerm(
                  expression: s.createdAt,
                ),
          ]))
        .get();
  }
  Future<void> markSynced(String id) async {
    await (update(localSales)
          ..where((s) => s.id.equals(id)))
        .write(
      const LocalSalesCompanion(
        state: Value('synced'),
        errorMessage: Value(null),
      ),
    );
  }
  Future<void> markNeedsReview(
    String id,
    String reason,
  ) async {
    await (update(localSales)
          ..where((s) => s.id.equals(id)))
        .write(
      LocalSalesCompanion(
        state: const Value('needs_review'),
        errorMessage: Value(reason),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Generate the Drift code:

dart run build_runner build --delete-conflicting-outputs
Enter fullscreen mode Exit fullscreen mode

We now have a local SQLite database that can persist sales without requiring network access.

For this small example, the pending sales themselves act as a durable synchronization queue.

In a larger application supporting many types of offline changes, a dedicated transactional outbox table is usually a better choice.

6. Recording a Sale Without Internet

Let’s create a sale directly from our Flutter application.

final database = PosDatabase();

final saleId = await database.saveSale(
  deviceId: 'register-01',
  items: [
    {
      'productId': 'coffee-001',
      'quantity': 2,
      'unitPriceMinor': 1500,
    },
    {
      'productId': 'cookie-001',
      'quantity': 1,
      'unitPriceMinor': 500,
    },
  ],
);
print('Locally recorded sale: $saleId');
Enter fullscreen mode Exit fullscreen mode

Two coffees at $15 each and one $5 cookie give a total of $35.

No API request is required for this operation.

The sale is persisted in SQLite, including its immutable sale ID and line-item snapshot.

If the application is restarted, the pending sale remains available.

That is the first major advantage of the design.

One important caveat: SQLite protects committed local records during ordinary application restarts and process termination. It cannot guarantee recovery after device loss, database destruction, or a factory reset before synchronization.

7. Building the Synchronization Engine

Now we’ll implement the component responsible for transmitting pending sales.

Our synchronization engine needs to follow four rules:

  1. Never delete a sale before server acknowledgment.
  2. Retry the same sale using the same ID and payload.
  3. Never treat a timeout as proof that the sale failed.
  4. Prevent overlapping synchronization runs within the application process.

Create lib/services/sale_sync_service.dart:

import 'dart:convert';
import 'package:http/http.dart' as http;
import '../data/pos_database.dart';
class SaleSyncService {
  final PosDatabase database;
  final Uri endpoint;
  final Future<String?> Function() getAccessToken;
  final http.Client client;
  bool _running = false;
  SaleSyncService({
    required this.database,
    required this.endpoint,
    required this.getAccessToken,
    required this.client,
  });
  Future<void> syncPending() async {
    if (_running) return;
    _running = true;
    try {
      final token = await getAccessToken();
      if (token == null) return;
      final sales = await database.pendingSales();
      for (final sale in sales) {
        try {
          final response = await client
              .post(
                endpoint,
                headers: {
                  'Content-Type': 'application/json',
                  'Authorization': 'Bearer $token',
                },
                body: jsonEncode({
                  'clientSaleId': sale.id,
                  'deviceId': sale.deviceId,
                  'items': jsonDecode(sale.itemsJson),
                }),
              )
              .timeout(
                const Duration(seconds: 12),
              );
          if (response.statusCode == 200) {
            final result = jsonDecode(response.body)
                as Map<String, dynamic>;
            if (result['status'] == 'accepted') {
              await database.markSynced(sale.id);
              continue;
            }
          }
          if ([400, 409, 422]
              .contains(response.statusCode)) {
            await database.markNeedsReview(
              sale.id,
              'Rejected with HTTP ${response.statusCode}',
            );
            continue;
          }
          // Auth errors or transient server failures
          // must not discard pending transactions.
          break;
        } catch (_) {
          // The server may have committed the sale.
          // Keep it pending and retry the same ID.
          break;
        }
      }
    } finally {
      _running = false;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The implementation intentionally does not attempt to guess whether a timed-out request was committed.

It simply keeps the sale pending.

When connectivity returns, the application sends the same ID and payload again.

If PostgreSQL already contains the sale, the server returns the previously created record.

This is much safer than assuming that every timeout represents a failed write.

8. When Should Synchronization Run?

There are several useful synchronization triggers:

  • When the application starts.
  • When the application returns to the foreground.
  • After recording a sale.
  • When the available network connection changes.
  • When the cashier taps a manual Sync button.

For network-change notifications, Flutter provides connectivity_plus:

flutter pub add connectivity_plus
Enter fullscreen mode Exit fullscreen mode

An example foreground listener:

import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
final subscription =
    Connectivity().onConnectivityChanged.listen((_) {
  unawaited(syncService.syncPending());
});
// Also try when the application starts.
unawaited(syncService.syncPending());
// Cancel the subscription when its owner is disposed.
Enter fullscreen mode Exit fullscreen mode

However, an important distinction exists:

Network connectivity does not guarantee internet access.

A device might be connected to Wi-Fi while the router has no internet connection.

Therefore, synchronization must still handle actual request failures and timeouts.

Also, a connectivity listener does not guarantee synchronization while the application is suspended or terminated. Reliable background behavior requires platform-specific scheduling, and mobile operating systems impose execution restrictions.

For an initial POS release, foreground synchronization combined with startup, resume, and manual retries is a sensible foundation.

9. Testing a Real-World Failure Scenario

Let’s walk through a concrete example.

A small retail store has one cashier and one Flutter POS device.

The cashier needs to record 10 sales.

Scenario A: The Internet Is Offline

The device loses connectivity.

The cashier continues recording sales.

Each sale is committed to SQLite with state pending.

Expected local state:

SALE 01   pending
SALE 02   pending
SALE 03   pending
SALE 04   pending
SALE 05   pending
SALE 06   pending
SALE 07   pending
SALE 08   pending
SALE 09   pending
SALE 10   pending
Enter fullscreen mode Exit fullscreen mode

Local sales count: 10.

Remote sales count: 0.

Scenario B: The Connection Returns

The synchronization engine sends each pending sale to NestJS.

The backend writes the sales to PostgreSQL.

After receiving valid acknowledgments, Flutter marks them as synchronized.

Expected state:

Local sales:         10
Remote sales:        10
Pending sales:        0
Duplicate sales:      0
Enter fullscreen mode Exit fullscreen mode

These are expected results for a correctly configured implementation, not measured benchmark results.

Scenario C: The Server Saves a Sale, but the Response Is Lost

Now imagine that the backend successfully commits SALE 05, but the device never receives its acknowledgment.

Flutter leaves SALE 05 as pending.

The synchronization engine retries.

Because the client sale ID already exists, NestJS returns the existing server sale instead of inserting a new one.

This is exactly the behavior we want.

Scenario D: The Same Sale ID Contains Different Data

Suppose a buggy client resends the original sale ID but changes the quantity from 2 to 5.

The backend’s payload hash no longer matches.

The endpoint returns HTTP 409 Conflict.

Flutter marks the local sale as requiring review rather than silently overwriting financial data.

10. What About Inventory Conflicts?

This is where offline-first POS architecture becomes more challenging.

Imagine two devices selling the same product while disconnected.

The store has five units remaining.

Device A sells four.

Device B also sells four.

Both sales are recorded locally.

When synchronization resumes, the combined quantity exceeds available stock.

No synchronization algorithm can retroactively prevent both disconnected devices from promising the same physical inventory.

We must define a business policy.

StrategyAdvantageTrade-offAllow offline sales and reconcile stockCheckout keeps workingStock may temporarily become negativeReserve device-specific inventory quotasStronger stock controlRequires allocation and replenishmentRequire online validationPrevents some oversellingCheckout stops when offline

For a small cash-based retail POS, a reasonable starting policy may be to accept valid offline sales and create inventory reconciliation tasks when necessary.

For high-value or tightly controlled inventory, offline quotas or online-only validation may be more appropriate.

In a production system, posting the sale, adjusting stock, and recording its financial effects should happen through an atomic server-side workflow.

The ingestion example above is the first layer of that architecture, not the complete inventory implementation.

11. Handling Offline Payments Safely

Offline sale recording and offline payment processing are not the same operation.

Our example can record a cash transaction locally.

It must not claim that a card payment was successfully authorized simply because the cashier saved a local record.

Card terminals, payment providers, and local regulations determine whether offline electronic payment capture is available and how settlement works.

A more complete POS should represent payment state separately from synchronization state.

For example:

Sale State:
- local_pending
- server_accepted
- needs_review

Payment State:
- cash_recorded
- authorization_pending
- authorized
- failed
- refunded
Enter fullscreen mode Exit fullscreen mode

This separation prevents the application from confusing a successfully synchronized sale with a successfully settled payment.

12. Production Improvements You Should Consider

Before releasing an offline-first POS to real businesses, there are several additional areas worth implementing.

Transactional Outbox

Our simple example stores pending sales in the sales table.

For larger applications, store domain changes and their outbox events in the same local database transaction.

This becomes especially useful when you need to synchronize sales, refunds, expenses, inventory adjustments, and customer changes.

Retry Policy

Instead of repeatedly retrying failed operations immediately, introduce exponential backoff with a maximum delay and jitter.

This prevents multiple devices from overwhelming the server after an outage.

Data Security

Encrypt sensitive local data where required, protect locally stored authentication credentials, and define rules for device revocation, logout, backup, and data retention.

Never trust a client-provided tenant ID or device identifier as sufficient proof of authorization.

Conflict Resolution

Treat conflicts as a business decision.

A sale should not be silently deleted because synchronization fails.

Provide a dedicated review screen and an audit trail explaining what happened.

Observability

Measure operational indicators such as:

  • Number of pending transactions.
  • Age of the oldest pending sale.
  • Successful synchronization rate.
  • Number of conflicting or rejected sales.
  • Server response and retry failures.

These metrics tell you whether the system is actually reliable in real stores.

Integration Testing

At a minimum, test the following cases:

TestExpected resultDevice offline during saleSale stored locallyApp restarted before syncPending sale preservedSame request retriedOnly one server saleTwo requests arrive concurrentlyOne server saleResponse lost after database commitSafe replaySame ID, modified payloadConflict returnedExpired access tokenPending sale retainedServer unavailablePending sale retainedInventory conflictBusiness policy applied

Integration tests against a real PostgreSQL database are particularly important because the unique constraint is what ultimately guarantees duplicate prevention.

13. Final Architecture

Our resulting system looks like this:

FLUTTER POS
                      |
              Cashier Records Sale
                      |
                      v
              Local Repository
                      |
                      v
               SQLite / Drift
                      |
              Pending Sales Queue
                      |
                      v
                 Sync Engine
                      |
                 HTTPS Request
                      |
                      v
                  NestJS API
                      |
             JWT + Tenant Validation
                      |
                      v
             Idempotency Handling
                      |
                      v
               PostgreSQL
                      |
               Unique Constraint
                      |
                      v
              Server Acknowledgment
                      |
                      v
               Flutter SQLite
                      |
               Mark Sale Synced
Enter fullscreen mode Exit fullscreen mode

This architecture gives us a strong foundation for a mobile or desktop POS application.

However, a production-grade implementation still needs pricing authorization, inventory reconciliation, settlement rules, local database migrations, multi-process synchronization locking where applicable, and operational recovery procedures.

Conclusion

Building an offline-first application is not just about storing data in SQLite.

The real challenge is handling what happens when the device and server temporarily disagree.

By combining Flutter, Drift, NestJS, Prisma, and PostgreSQL, we can build a system where sales remain available locally, failed requests can be retried, and the backend avoids creating duplicate records.

The most important architectural decisions are:

1. Persist before synchronizing. A sale should not disappear because the network is unavailable.

2. Reuse immutable transaction identifiers. Retries must not create new business transactions.

3. Enforce idempotency at the database level. Client-side retry logic alone is not sufficient.

4. Separate local recording from server acceptance. Each state has different business implications.

5. Design conflict handling before deploying. Offline synchronization cannot eliminate every inventory, pricing, or financial conflict.

Ultimately, a good offline-first architecture is about maintaining trust in the application’s data even when the network cannot be trusted.

Reliable software doesn’t assume that the network will always work. It is designed for what happens when it doesn’t.

References

Top comments (0)