Skip to content
Operations Platform

Servia

Built Servia as an end-to-end restaurant operations platform handling orders, reservations, payments, and live kitchen state. Engineering focus was correctness under concurrency: idempotent payment processing to survive client retries, serializable transaction isolation to prevent double-bookings and overselling, and a JWT dual-token (access + refresh) auth flow with revocation. Shipped through a full CI/CD pipeline with automated linting, type-checking, test, and build gates.

double_bookings
0 since launch
idempotency
Zero duplicates in retry tests
write_latency
<200ms p95
Servia

The Problem

Restaurants managing operations using legacy single-client setups or offline tickets frequently suffer from data synchronization lag. Kitchen staff, hosts, and delivery operators work off disparate states, leading to lost orders, double-booked reservations during peak hours, and uncoordinated inventory counts that slow down table turn times.

Constraints

  • Idempotent writes — clients on flaky restaurant Wi-Fi retry; duplicate requests must not process multiple payments or create duplicate orders.
  • Serializable isolation for reservations — concurrent bookings for the last available table slots must be resolved serializably to prevent overbooking.
  • Stateless auth that survives token leaks — short-lived access tokens combined with secure, HttpOnly refresh-token rotation.
  • CI gates blocking faulty code merges — mandatory automated linting, strict type-checking, integration tests, and build checks before main integration.

Architecture

Servia Booking Concurrency Flow

PostgreSQL SERIALIZABLE Isolation vs. Concurrent Write Conflicts (ErrorCode P2034)

Transaction Abort / 409
Success / 201
1. Client Tier (React SPA)2. Application Tier (Express + Prisma)3. Database Tier (PostgreSQL 15)Submit BookingPOST /api/reservationsJSON PayloadExpress Controllerprisma.$transaction(...,{ isolationLevel: 'Serializable' })1. Read CapacityCapacity CheckSELECT SUM(partySize)WHERE time = target_slotReturns Current SumEvaluate Capacity LimitSum + Party Size <= 502. Write RowsPostgreSQL MVCCINSERT INTO reservationTrack Read-Write SetMonitor concurrent commitsConflict?No (Commit Success)Yes (Abort Conflict)Transaction AbortedErrorCode P2034Success HandlerReturns Created Booking201 Created (Confirmed)ConfirmedOptimistic UI ResolvesThrows P2034Express Catch BlockMaps P2034 → 409 ConflictInforms client to retry409 Concurrency ConflictAutomatic RetryTriggers immediate re-submitClient Loop Retry

1. Client Request & Retry

The Next.js client submits booking parameters. In case of database conflict, the API returns a 409 status code, prompting the client UI to immediately re-fire the transaction.

2. Serializable Context

Prisma wraps the capacity check and creation in a transaction block set to SERIALIZABLE. Rather than placing row locks, it registers the read/write constraints.

3. Conflict Detection (P2034)

If PostgreSQL detects a concurrent transaction has inserted a record that changes the capacity sum since this transaction started, it aborts, throws Error P2034, and avoids overbooking.

The backend is structured as a stateless Express API interacting with a PostgreSQL database via Prisma ORM, while the frontend is a Next.js single-page application. Security relies on a custom JWT dual-token flow where short-lived access tokens verify endpoints in-memory, and long-lived refresh tokens are stored in secure, HttpOnly, SameSite=Strict cookies and matched against bcrypt hashes in the database. Concurrency control is delegated directly to PostgreSQL using Serializable transaction isolation levels to prevent race conditions during write-heavy operations.

Key Decisions

Custom JWT dual-token authentication flow over server-side session cookies.

Rationale: Maintains a completely stateless backend API allowing horizontal scale-out without session store bottlenecks or database lookups on every request.

Tradeoff: Increases client-side storage complexity and requires explicit refresh token database bcrypt hashing and rotation to manage active session revocation.

SQL SERIALIZABLE isolation level over application-level optimistic/pessimistic locking.

Rationale: Guarantees absolute transactional correctness for aggregate calculations (like summing total parties per slot) without needing pre-seeded slot inventory rows.

Tradeoff: Results in higher transaction abort rates under extreme write contention, requiring explicit retry logic at the application layer or client failure handling.

Payment and order idempotency keys checked at the database layer.

Rationale: Prevents double-charging on network retry requests by verifying the payment intent reference inside an atomic database block before contacting the payment gateway.

Tradeoff: Introduces slight storage overhead for tracking transaction reference keys and requires API endpoints to accept client-generated UUIDs.

Separate Express.js API server alongside Next.js Frontend monorepo.

Rationale: Ensures clean service boundaries and allows the Express backend to support future service clients (e.g. tablet or mobile merchant interfaces) without coupling API logic to Next.js serverless cold starts.

Tradeoff: Increases deployment coordination and local setup complexity, requiring cross-origin resource sharing (CORS) configuration and a shared package (@packages/shared) to prevent type contract drift.

Post-Mortem: What I Learned

The overbooking bug surfaced when we ran concurrency load tests on the reservation API. We simulated twenty clients trying to book the last remaining table slots at the exact same millisecond. Because the database was running on default Read Committed isolation, two concurrent HTTP threads read the same seating capacity sum (e.g., 48 seats booked out of a maximum 50) and both calculated that there was room for their parties of 4. Both queries evaluated as valid, bypassed the application check, and successfully committed their rows. We ended up with 56 seats booked in a 50-seat room. Under load, this wasn't a rare edge case — it happened consistently whenever capacity neared its limit and clients clicked 'Book' at the same time.

Reflection: What Changed

We wrapped the entire check-and-insert flow inside a Prisma transaction block and forced PostgreSQL to run it at the SERIALIZABLE isolation level. Now, instead of allowing concurrent sessions to read stale state, the database engine tracks read-write dependencies. When PostgreSQL detects that a concurrent transaction has inserted a new booking since our query started, it aborts the second write and throws error P2034. The service layer intercepts this code, translates it to a 409 Concurrency Conflict, and invites the client to retry. It pushes the serialization collision handling to the application boundary where it belongs, ensuring we never overbook a room.

Load Testing Evidence

Testing Toolk6
Peak ThroughputApproximately 350 RPS (Reads) / 120 RPS (Writes)
Test EnvironmentLocal Docker container (1 vCPU, 1GB RAM) simulating Railway node environment, connected to PostgreSQL 15 database on 2 vCPU, 4GB RAM tier

Tested with 50 concurrent Virtual Users (VUs) executing booking creations over 30s. P95 latency remained under 180ms. With extreme capacity slot contention, 14.5% of requests collided and threw PostgreSQL P2034 serialization errors. All aborted writes successfully bubbled to the client layer and completed cleanly on automatic client retries, ensuring zero double-bookings occurred.

Test script documented in docs/load-test-k6.js ↗

Ready to build something like this?

Whether you need a production backend, a marketplace, or a fintech product built right — let's talk about your project.