Skip to content

ARTICLE // ARCHITECTURE

Engineering Real-Time Location Relays & Ledger Splits in On-Demand Marketplaces

How we architected high-frequency GPS journey tracking with Expo background tasks, NestJS WebSocket gateways, and automated split payout ledgers in Servyq.

February 15, 2025
3 min read
Architecture, React Native, NestJS, WebSockets, PostgreSQL

When building Servyq — an on-demand service marketplace connecting domestic seekers with service providers — we faced two core architectural challenges: keeping battery-efficient live GPS telemetry synchronized across mobile devices, and executing safe, verifiable split payments upon job completion.

01 // SECTION01. The Real-Time Location Pipeline

Live tracking for en-route providers cannot rely on standard HTTP polling without draining mobile battery and overwhelming backend ingress.

┌───────────────────────────┐ ┌───────────────────────────┐ │ React Native / Expo Client│ │ NestJS Server │ │ (expo-location background)│ │ (WebSocket Gateway) │ └─────────────┬─────────────┘ └─────────────┬─────────────┘ │ │ │ 1. GPS Location Relay (Socket.io) │ ├────────────────────────────────────>│ │ │──┐ 2. Validate & Broadcast │ │ │ to Booking Room │ 3. Live Coordinate Stream │<─┘ │<────────────────────────────────────┤ │ │

Background Geolocation Relaying

On the mobile client, we configured expo-location combined with expo-task-manager to capture coordinates even when the app is backgrounded or the screen is locked:

TS
import * as Location from 'expo-location';
import * as TaskManager from 'expo-task-manager';

export const LOCATION_TRACKING_TASK = 'SERVYQ_BACKGROUND_GPS_TASK';

TaskManager.defineTask(LOCATION_TRACKING_TASK, async ({ data, error }) => {
  if (error) {
    console.error('Location tracking task failed:', error);
    return;
  }

  if (data) {
    const { locations } = data as { locations: Location.LocationObject[] };
    const latest = locations[0];
    if (latest) {
      socketService.emit('provider:location_update', {
        latitude: latest.coords.latitude,
        longitude: latest.coords.longitude,
        heading: latest.coords.heading,
        speed: latest.coords.speed,
        timestamp: latest.timestamp,
      });
    }
  }
});

To preserve device battery, we established adaptive distance filters: coordinates are only broadcast when the provider displaces by more than 15 meters or every 10 seconds.

02 // SECTION02. Deterministic State Machine

A booking lifecycle follows a strict transition state machine enforced by backend guards:

[Upcoming] ──> [Arriving] ──> [Arrived] ──> [In Progress] ──> [Completed] │ │ │ │ └─── [Cancelled (with automatic fee calculation)] ◄───┘

Every transition generates an immutable audit entry in PostgreSQL via Prisma, guaranteeing that clients cannot trigger out-of-order state mutations (e.g. attempting to complete a service before arriving).

03 // SECTION03. Dynamic Ledger Splits & Commission Routing

Marketplaces must guarantee platform commission retention while routing service provider payouts directly into verified accounts without manual intervention.

We integrated Razorpay Route and Cashfree Split to dynamically divide checkout balances at settlement:

TS
export async function executeSplitPayout(bookingId: string, totalAmount: number, providerAccountId: string) {
  const platformFee = Math.round(totalAmount * 0.15); // 15% Platform Commission
  const providerPayout = totalAmount - platformFee;

  return await prisma.$transaction(async (tx) => {
    // 1. Create Transaction Ledger Entry
    const ledger = await tx.paymentLedger.create({
      data: {
        bookingId,
        totalAmount,
        platformFee,
        providerPayout,
        status: 'PROCESSING',
      },
    });

    // 2. Dispatch Split Transfer to Payment Gateway
    const transfer = await razorpay.transfers.create({
      account: providerAccountId,
      amount: providerPayout * 100, // in paise
      currency: 'INR',
      notes: { bookingId, ledgerId: ledger.id },
    });

    // 3. Mark Ledger Settled
    await tx.paymentLedger.update({
      where: { id: ledger.id },
      data: { status: 'SETTLED', gatewayTransferId: transfer.id },
    });

    return ledger;
  });
}

04 // SECTIONKey Takeaways

  • Decouple high-frequency location updates through dedicated WebSocket channels instead of persistent database writes.
  • Maintain an append-only payment ledger to make financial reconciliations deterministic and auditable.
React Native
Next.js
TypeScript
Node.js
NestJS
PostgreSQL
Prisma
Redis
Docker
GraphQL
Tailwind CSS
MongoDB
Express
AWS
Socket.io
Expo
System Design
REST APIs
Git
React Native
Next.js
TypeScript
Node.js
NestJS
PostgreSQL
Prisma
Redis
Docker
GraphQL
Tailwind CSS
MongoDB
Express
AWS
Socket.io
Expo
System Design
REST APIs
Git