Skip to content

EPOS Offline-First POS — Interview Revision Sheet

The one-line summary

A multi-client offline-first Angular POS with IndexedDB persistence, a background sync queue, per-customer order buffers, and Ports and Adapters architecture sharing domain logic across online and offline data sources — with cold start reduced from 40s to 6s and offline file generation from 10 mins to 8s via context-first batch processing.


The Offline-First Architecture

What lives in IndexedDB

Product listing information, prices across every price type, channel configuration, and default walk-in customer information. This is the offline catalog — everything the till needs to operate without any network connectivity.

How the catalog stays current

A partial sync process runs via polling. When a price changes or product data updates, the backend triggers a recompute and generates a partial sync payload. The EPOS pulls this on its poll interval, updates the local IndexedDB catalog.

The staleness window: there is a small window where the local catalog can be behind the real price. This is an accepted, known tradeoff — not a gap to hide.

How staleness is handled at order time: - The local catalog price is what the customer sees and is quoted (advertised price) - At order sync time, the sync process detects if the local price used differs from the current price - Registered customer: order is created but placed on hold, email triggered to customer requesting approval of the price change - Walk-in customer: cashier gets an inline warning, approve button required before order completes - If sync fails simultaneously with a stale price on a walk-in order: business honors the advertised price, takes the loss. The customer was quoted that price — that's the policy, not a system gap.

Why not enforce a live price check at add-to-cart: this defeats the primary purpose of an offline-first POS. If adding an item requires a network call, the system fails when the network drops mid-shift. Offline-first means cart operations never depend on connectivity. All staleness handling works with data already local.


Ports and Adapters — I<Entity>Api

The pattern

State services in the Angular app query I<Entity>Api — a contract that defines what the app needs from its data source. The app has no knowledge of what's behind the interface.

Three possible implementations: - IndexedDB adapter — reads from local IndexedDB, used in offline mode - REST adapter — calls actual backend API endpoints, used in online mode - External ERP adapter — could be Exact Online or any other platform, if it implements the contract it's usable

What this buys: swap the data source without changing any application logic. Pricing, promotions, and payments domain logic is shared across online and offline paths — the same business rules execute regardless of where the data comes from, as long as the adapter satisfies the contract.

This is DDD at the frontend: the domain (pricing rules, promotion logic, payment handling) is independent of infrastructure (which DB or API you're talking to). Domain boundaries enforced at the frontend, not just the backend.


Per-Customer Order Buffers — Hold Tokens

What it is

A quality-of-life feature for cashiers handling multiple customers. Not an architectural marvel — a deliberate UX improvement.

The problem it solves

In a retail/queue context, a customer at checkout might decide they want to add one more item and go back to browse. Without buffers, the cashier either loses the half-built order or blocks the queue waiting.

How it works

The cashier issues a hold token for the current customer's order. The order state is preserved in the per-customer buffer. The cashier can switch context — serve another customer, assist someone else — then resume the held order when the customer returns with the hold token. State is fully preserved across the switch.

When presenting this: don't pre-apologize for its simplicity ("not a technical marvel"). It's a real feature solving a real cashier workflow problem. State what it does and why it matters — that's enough.


Contextual Inline Flows

What it replaces

The legacy EPOS was a cascade of redirects, tabs, and modal popups: - Stock levels per warehouse: open a popup - Change price types: open a popup - Search for a customer: two separate input boxes (keyword search, ID search) inside a popup - Everything layered on top of everything else

What replaced it

Inline panels directly on the main workflow screen. The information is visible without breaking context — cashier never loses their place in the current order to look something up. Stock levels, price type switching, customer search all accessible inline without modals or redirects.

The technical question if pushed: inline panels are component-level state — each panel manages its own visibility and data, isolated from the order flow state. No routing changes, no modal overlay stack. Angular component composition handles the isolation.


Cold Start: 40s → 6s

What was wrong — three distinct root causes

1. Unused IndexedDB indexes being built on every bootstrap Indexes that were defined but never actually queried upon. Pure dead weight — building them on every bootstrap for no benefit. Removing them cut startup time directly.

2. Sequential per-record deletion on full sync Full sync was deleting existing data one record at a time before reloading. O(n) delete operations where a single bulk clear operation would do. IndexedDB supports clearing an object store in one operation — the legacy implementation wasn't using it.

3. Independent fetches run sequentially Configuration fetch and Products fetch had no dependency on each other — neither needed the other's result to proceed. They were running sequentially anyway. Running them in parallel cut the combined wait time to the duration of the slower of the two, not the sum of both.

Backend side

Same LTCP pattern applied to the bootstrapping flow: identify what data the frontend needs to boot, batch-load it, shape it, send it. Smarter data access, bulk patterns, parallelization — same discipline applied at the backend bootstrap endpoint.

Why "backend and frontend" both changed

Frontend: removed dead index builds, bulk clear instead of sequential delete, parallel independent fetches. Backend: consolidated the data the bootstrap endpoint serves into a leaner, pre-shaped payload rather than requiring multiple round trips or over-sending.


Offline File Generation: 10 mins → 8s

What the file is

A complete product catalog snapshot: product listing information and its price across every price type. The till loads this file to operate with full pricing capability while fully offline (no API connectivity at all). 14,000 products, all price types computed.

What the old generation was doing

Per product, per price type: - Product listing info: 1 DB call - SKU fetch: 1 DB call - Product type fetch: 1 DB call - Price calculation: called the old unoptimized pricing engine once per price type per SKU

The old pricing engine itself: 8-10 DB calls internally. So for 10 price types per SKU, this was 3 + (10 × 8-10) DB calls per product. Across 14,000 products, this is the real magnitude of why generation took 10-25 minutes.

The hidden dependency: the file generator was one of the largest consumers of the old pricing engine's cost — calling it thousands of times in a loop, inheriting the full per-product call sprawl on every generation.

What LTCP did to this

Same pattern as pricing: batch-load all required data for all 14,000 products upfront (two queries regardless of batch size), build in-memory context, compute all prices from context, write the file. DB round trips: 4 per product → 3 per batch of 500.

Note on the "8s" number: 8s is the cold start figure (no warm caches, fresh everything). Typically ~5s in practice. Resume says 8s — use that, don't self-correct to 5 mid-interview. If asked, you can say "8s cold start, typically around 5s in practice."

Batch failure tradeoff: the batch approach means a single bad product can fail a batch of 500, not just that one product. This is the cost of reducing granularity. Accepted tradeoff: batch failure is rare in a correctly-functioning system (good data, no exception-prone retry loops), and the gain (eliminating the per-product call sprawl) is orders of magnitude more valuable than the fine-grained retry granularity that existed before.


Connection to the Pricing Engine Story

The EPOS file generation, EPOS cold start, order processing, and invoice generation improvements are all applications of the same Load→Transform→Compute→Persist pattern built for the pricing engine. When presenting this:

"I built this pattern for pricing, then applied the same discipline wherever the same DB-call sprawl existed — EPOS file generation, bootstrapping, order processing."

This is a stronger story than presenting each as an isolated win. The pattern is the contribution, not just each individual performance number.


Numbers — Lock These Down

Metric Before After
Cold start 40s 6s
Offline file generation 10 mins 8s (cold), ~5s typical
DB calls (file gen) 4 per product 3 per batch of 500
Products in file 14,000 14,000

Follow-up Questions to Be Ready For

"What happens when the local price is stale at checkout?" Detected at order sync time. Registered customers: order held, approval email sent. Walk-ins: inline cashier warning, approve-before-complete gate. If sync fails simultaneously: business honors the advertised price, takes the loss — policy decision, not a system gap.

"Why not enforce a live price check when an item is added to cart?" Defeats the purpose of offline-first. Cart operations must never depend on connectivity. Network drops mid-shift — cashier needs to keep working. All staleness handling is done with data already local.

"What does Ports and Adapters buy you at the frontend?" Data source is swappable without changing application logic. IndexedDB adapter, REST adapter, external ERP adapter — if it implements I<Entity>Api, it's usable. Domain logic (pricing, promotions, payments) runs identically regardless of where the data comes from.

"What caused the 10-minute file generation time?" Hidden dependency on the old pricing engine. Generator called price calculation once per price-type per SKU across 14,000 products. Old engine had 8-10 DB calls per product internally. Generator was inheriting that cost thousands of times. LTCP eliminated it — batch load, in-memory compute, no per-product calls.

"What was wrong with the cold start?" Three root causes: unused IndexedDB indexes built on every bootstrap, sequential per-record deletion instead of bulk clear on full sync, and independent Configuration + Products fetches run serially instead of in parallel. Plus backend bootstrapping endpoint redesigned with same LTCP discipline.

"What's the hold token mechanism?" Per-customer order buffer. Cashier issues a hold token, order state preserved in buffer, context switch to another customer, resume on hold token presentation. UX feature for queue/multi-customer retail workflows — preserves cashier state without blocking or losing orders.

"What's the batch failure tradeoff in file generation?" Reducing granularity from per-product to per-batch-of-500 means one bad product can fail a batch. Accepted cost — in a correctly functioning system (good data, no retry-loop chaos), batch failures are rare. The gain (eliminating per-product call sprawl across 14K products) is orders of magnitude more valuable than fine-grained retry granularity.