Get a quote

Building a Multi-Location Data Sync System for Restaurant and Retail Chains in MENA

Managing data across multiple branches is one of the harder operational problems restaurant and retail chains in Lebanon face. When the network drops, when two locations update the same record concurrently, or when a branch comes back with an hour of backlogged transactions, a naive sync system creates data inconsistencies that take days to untangle.

Managing data across multiple branches is one of the harder operational problems that restaurant and retail chains in Lebanon and the MENA region face. Each branch generates its own stream of sales, inventory changes, and customer interactions. The head office needs a unified view. When the network drops, when two locations update the same record concurrently, or when a branch runs offline for an hour and comes back with a backlog of transactions, a naive sync system creates data inconsistencies that take days to untangle.

What makes multi-location sync hard

The problem is not collecting data from two places. The hard problems are:

A branch in North Lebanon records a product return at the same moment the head office pushes a price change for that product. Which write wins?

Internet connectivity drops at a branch during a Friday lunch rush. Forty-five minutes of transactions are recorded locally. When connectivity returns, how does the system handle the backlog without duplicating or losing records?

A branch employee manually overrides a product price without authorization. When that change syncs to the central system, how does the system detect and handle the unauthorized modification?

The answers to these questions determine the architecture of the sync system.

Central vs. distributed data model

Two fundamental approaches exist for multi-location data architecture.

Fully centralized: every branch connects directly to a single cloud database. Every sale, inventory movement, and price update is written directly to the central database in real time. This is simple but depends entirely on stable internet connectivity. In Lebanon, where connectivity is intermittent at many locations, this model is operationally risky.

Hybrid local-central: each branch has a local database (SQLite or a local PostgreSQL instance) that operates independently. Data syncs to the central system in real time when connectivity is available, or in batches when connectivity returns. This is more complex but reliable in environments with unstable connectivity.

For most restaurant and retail chains in Lebanon, the hybrid model is the practical choice.

Designing the sync mechanism

A reliable sync system tracks every change as an independent event rather than syncing full table states.

For every operation (sale completed, inventory adjusted, price updated), the system creates an event record:

CREATE TABLE sync_events (
    id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    branch_id       bigint NOT NULL,
    event_type      text NOT NULL,   -- 'sale_completed', 'inventory_adjusted', 'price_updated'
    entity_type     text NOT NULL,
    entity_id       bigint NOT NULL,
    payload         jsonb NOT NULL,
    occurred_at     timestamptz NOT NULL,
    synced_at       timestamptz,
    sync_status     text NOT NULL DEFAULT 'pending',  -- 'pending', 'synced', 'conflict'
    idempotency_key text UNIQUE NOT NULL
);

CREATE INDEX idx_sync_events_pending ON sync_events (branch_id, occurred_at)
    WHERE sync_status = 'pending';

This is the event sourcing approach. Every change is documented and ordered in time. Conflict resolution becomes a logical process rather than a guessing game.

Resolving conflicts from concurrent updates

The most common conflict in retail systems is inventory updates from two sources.

Consider: current stock for a product is 100 units. A branch records a sale of 5 units at the same time the head office adjusts stock to 95 after a manual count. The correct result is 90 units (95 minus the 5-unit sale). A naive system takes the last written value, which may be 95 or 90 depending on timing, and silently drops one of the updates.

The correct approach uses delta operations rather than absolute values:

type InventoryEvent struct {
    ProductID  int64
    Delta      int    // positive for additions, negative for reductions
    Reason     string // 'sale', 'count_adjustment', 'receipt', 'void'
    OccurredAt time.Time
    BranchID   int64
}

func (s *SyncService) ApplyInventoryEvents(ctx context.Context, events []InventoryEvent) error {
    // Sort events by occurred_at to apply in chronological order
    sort.Slice(events, func(i, j int) bool {
        return events[i].OccurredAt.Before(events[j].OccurredAt)
    })

    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    for _, event := range events {
        _, err = tx.ExecContext(ctx, `
            UPDATE inventory
            SET quantity = quantity + $1,
                updated_at = $2
            WHERE product_id = $3 AND location_id = $4
        `, event.Delta, event.OccurredAt, event.ProductID, event.BranchID)
        if err != nil {
            return err
        }
    }

    return tx.Commit()
}

Instead of writing an absolute value, each event records a delta. Applying both events in chronological order: decrease by 5 (the count adjustment from 100 to 95), then decrease by 5 more (the sale), yields 90. Both changes are preserved.

For conflicts that cannot be resolved automatically (such as a price changed from two different sources), define clear priority rules: head office changes take precedence over branch changes, or route the conflict to a supervisor queue for manual resolution.

Handling network outages

Each branch maintains a local event queue for unsynced changes. When connectivity drops, events continue accumulating locally. When connectivity returns, the branch sends the accumulated events to the central server in chronological order:

func (s *BranchSyncClient) SyncPendingEvents(ctx context.Context) error {
    events, err := s.localDB.QueryContext(ctx, `
        SELECT id, event_type, entity_type, entity_id, payload, occurred_at, idempotency_key
        FROM sync_events
        WHERE sync_status = 'pending'
        ORDER BY occurred_at ASC
        LIMIT 500
    `)
    if err != nil {
        return err
    }

    batch := collectEventBatch(events)

    resp, err := s.centralClient.BatchApplyEvents(ctx, &BatchApplyRequest{
        BranchID: s.branchID,
        Events:   batch,
    })
    if err != nil {
        return err // Will retry on next sync cycle
    }

    // Mark successfully applied events as synced
    for _, appliedID := range resp.AppliedIDs {
        s.localDB.ExecContext(ctx, `
            UPDATE sync_events SET sync_status = 'synced', synced_at = now()
            WHERE id = $1
        `, appliedID)
    }

    // Mark conflicts for supervisor review
    for _, conflictID := range resp.ConflictIDs {
        s.localDB.ExecContext(ctx, `
            UPDATE sync_events SET sync_status = 'conflict'
            WHERE id = $1
        `, conflictID)
    }

    return nil
}

Critical design requirements: every event has a unique idempotency key to prevent double-processing. Each branch tracks the last successfully synced event to the central server. Events are retained after sync as a complete audit trail.

Building the sync status dashboard

Operations staff need visibility into sync state across branches. A minimal dashboard shows:

  • Last successful sync time per branch
  • Number of pending events per branch
  • Conflicts awaiting manual resolution
  • Branches that have been offline for more than a configured threshold
CREATE VIEW branch_sync_status AS
SELECT
    b.id AS branch_id,
    b.name AS branch_name,
    COUNT(*) FILTER (WHERE se.sync_status = 'pending') AS pending_events,
    COUNT(*) FILTER (WHERE se.sync_status = 'conflict') AS conflict_count,
    MAX(se.synced_at) AS last_synced_at,
    EXTRACT(EPOCH FROM (now() - MAX(se.synced_at))) / 60 AS minutes_since_sync
FROM branches b
LEFT JOIN sync_events se ON se.branch_id = b.id
GROUP BY b.id, b.name;

Production lessons from Lebanese deployments

Test the outage scenario before launch. Most development teams do not test what happens when the internet drops for 30 minutes and then returns. This is the most common failure scenario in the Lebanese operating environment. Simulate it deliberately in staging before going live.

Do not try to sync everything in real time. Some data does not need immediate synchronization. Sales data is critical. Display configuration and screen layout settings can sync nightly. Segment your sync events by urgency and process them at appropriate intervals.

Build conflict visibility into the system. Supervisors need to see unsynced events, pending conflicts, and last sync time per branch. A visible dashboard turns a hidden technical problem into one that operations staff can manage without involving engineering.

Expire old pending events after a defined threshold. An event from 72 hours ago that has not synced should trigger a manual review, not an automatic application. The business context that makes the event valid may no longer apply.


Key lessons from production

Multi-location data sync is not only for large enterprises. Any restaurant or retail chain with two or more locations faces these challenges. The right architecture tracks events rather than syncing full table states, resolves conflicts through defined rules, handles network outages as a first-class concern from day one, and gives operations staff visibility into sync health without requiring engineering involvement.


Need a multi-location system built for your chain?

Voxire builds distributed management systems for restaurant chains, retailers, and multi-location businesses across Lebanon and the MENA region. Reach out at https://voxire.com/get-a-quote/

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Back to blog
Chat on WhatsApp