Firebase can own your user and order context without becoming a second authority for every seat in the venue.
The short version
An Ionic app needs three different kinds of state in a reserved-seating flow:
- Application state: identity, event catalogue, cart, payment intent, and commercial order.
- Buyer state: what the person is looking at and which seats they intend to hold.
- Inventory state: which seats are free, held, sold, blocked, or expired for this event.
Firebase is a practical home for the first category. The Ionic surface renders the second. The third needs one authoritative inventory transition owner. If the app stores a copy of open/held/sold seats in Firestore and also calls a seat API, it now has two systems that can disagree during the exact moment a buyer is paying.
That ownership split is the foundation of an Ionic real-time seat map; the listener technology is secondary to deciding which system may accept or reject a hold.
We want to be clear this is an architecture problem, not an argument that Firebase is “bad” for ticketing. We ship on Firebase constantly.
Two buyers, one seat
Consider a synthetic event with one free seat, STALLS-A-12.
| Time | Buyer A | Buyer B | Authoritative result |
|---|---|---|---|
| 10:00:00 | Receives the event chart | Receives the event chart | Both see the seat as free |
| 10:00:02 | Taps A-12 | — | Both clients may still show free until a hold succeeds |
| 10:00:03 | Requests a hold | Requests a hold | Inventory service serializes the two requests |
| 10:00:03 | Receives holdId and expiry | Receives a conflict | One accepted transition, one recoverable failure |
| 10:00:04 | Continues to checkout | Seat is removed from selection | UI reflects server state, not the old Firestore snapshot |
This is a simulated teaching trace, not production telemetry. It demonstrates the state boundary: the browser can express intent, but a client listener cannot decide which buyer owns the seat.
We let our fixture run past the convenient part of the demo. Buyer A’s hold expires, the inventory
returns to free, Buyer B obtains a new hold, and the host books it using order_fixture_42. The
script then repeats the booking after an intentionally ambiguous response and asserts that the same
order result is returned. The eight transitions are deterministic and executable with Node; they
are not a latency or production-concurrency benchmark.

Give every state one owner
Write the ownership table before wiring listeners.
| State | Owner | What the Ionic app does |
|---|---|---|
| User identity/profile | Host authentication/Firebase | Reads the signed-in user and app permissions |
| Event catalogue | Host product/Firebase or existing backend | Chooses the trusted event key |
| Published chart geometry | Seat-map platform | Renders the chart for that event |
| Open/held/sold/blocked inventory | Seat-map event inventory | Displays current status and requests holds |
| Selection UI | Ionic buyer surface | Shows intent; clears or reconciles it when the server disagrees |
| Cart/payment/commercial order | Host backend and payment system | Creates the order and charges the buyer |
| Inventory booking | Trusted host server calling the seat API | Books with the host order reference and handles retries |
| Audit/reconciliation | Host operations plus provider webhooks | Repairs and explains uncertain transitions |
The table prevents the mistake we see most often: treating an application database record such as
events/{id}/seats/{label} as if it were automatically the inventory authority. It can be a cache,
search projection, or reporting record. It should not silently become a second writer.
The rule also survives multiple sales channels. A box-office terminal, web buyer, Ionic app, and partner allocation may present different interfaces, but none should bypass the same event inventory authority. Channel-specific carts can live in Firebase; the seat transition cannot be “last client write wins.”

Mount the buyer surface in Ionic
The browser SDK is framework-agnostic. In an Ionic application, mount it after the container exists and destroy it when the page leaves the view.
We have kept the following deliberately small and Angular-shaped. Use the equivalent client-only lifecycle hook in Ionic React or Vue.
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from "@angular/core";
import { SeatingChart } from "@seatlayer/js";
@Component({
selector: "app-seats",
template: `<div #seatMap class="seat-map" aria-label="Choose your seats"></div>`,
})
export class SeatsPage implements AfterViewInit, OnDestroy {
@ViewChild("seatMap", { static: true }) seatMap!: ElementRef<HTMLElement>;
private chart?: SeatingChart;
async ngAfterViewInit() {
this.chart = new SeatingChart({
container: this.seatMap.nativeElement,
event: this.eventKeyFromTrustedRoute(),
maxSelection: 6,
onHold: (result) => this.checkout.setHold(result.holdId),
});
await this.chart.render();
}
ngOnDestroy() {
this.chart?.destroy();
}
}
Note that we do not put a Firebase secret or account credential in the page. The event key can come from a trusted product record or route. The hold callback gives the host cart an opaque reference; it does not make the browser the booking authority.
In Ionic React or Vue, the lifecycle names change, but the rule does not: create one chart instance when the view becomes active, keep the controller in a ref, and destroy it when the route leaves. A common mobile bug is not a Firebase race at all; it is two chart instances listening to the same route after a tab switch. The second instance renders an apparently “live” map while the first one still owns the hold callbacks. Log an instance ID during mount and teardown so that this class of leak is visible in a device trace.
Avoid rendering the chart during server-side rendering. Give the map a definite height and do not nest its pan/zoom surface inside another gesture-driven scroll container. On mobile, test keyboard appearance, safe areas, rotation, back navigation, and page re-entry.
Carry a hold through checkout
The client-to-server contract should be small:
type CheckoutRequest = {
eventKey: string;
holdId: string;
cartId: string;
};
The server does the sensitive work:
- load the secret from server configuration;
- inspect the hold for the trusted event;
- calculate the payable amount from authoritative hold items;
- create or confirm the host payment intent;
- book with the stable host order ID as
bookingRef; - release the hold if payment fails and immediate release is useful;
- accept signed webhooks idempotently for later inventory changes.
Do not accept a buyer-supplied price, currency, tier, or seat label as the payment authority. A client can display a price for a good experience; the server must calculate the amount it charges.
This is the part of the real-time seat inventory API that belongs in an architecture discussion: the chart and live inventory are a service boundary, not another Firebase collection to update optimistically.
Why listeners are not enough
Firestore listeners are excellent for application data that benefits from a shared document model. They do not automatically solve every inventory problem:
- a listener can be delayed while another buyer is acting;
- a client can be offline and resume with stale data;
- two clients can observe the same free state before either write completes;
- a UI write can be retried without the server knowing whether the first attempt committed;
- a copied seat document can drift from the published chart or event state.
A DIY Firebase-only design can still be valid. It needs a deliberate transaction model, one write authority, idempotency, expiry jobs, price authority, conflict responses, and operational tooling. Those are the costs the architecture is making visible.
Live updates and reconnects
The buyer should not need a full page reload every time inventory changes. A live inventory contract can send an initial snapshot followed by changes such as hold, book, release, block, and expiry. On reconnect, the client needs a fresh snapshot or a documented resynchronization path.
Treat the live stream as a view of the authoritative event state:
connect → snapshot
hold → delta: held
book → delta: booked
release → delta: free
expire → delta: free
reconnect → fresh snapshot
Do not merge a stale local selection back into a fresh snapshot without checking the hold. If the buyer had a valid hold, resume it through the documented hold flow. If the hold expired, explain what happened and reopen selection.
Conflict and failure behavior
The interface needs a state for each normal failure:
| Failure | Buyer-facing response | Server/operations action |
|---|---|---|
| Hold expired | Clear the stale choice and return to the map | No manual repair; inventory can return to sale |
409 seat conflict | Identify unavailable seats and preserve valid choices | Log request/order context without secrets |
| Payment declined | Keep or release according to product policy | Release the hold when appropriate |
| Booking timeout | Show pending/retry state, not “failed” by default | Retry the same bookingRef and reconcile |
| Duplicate webhook | Ignore the duplicate after verification | Keep an idempotent delivery record |
| Client reload | Reconnect and inspect current hold | Never trust the previous local selection alone |
The seat hold and checkout flow is the right deep reference for this section. Link it beside the implementation behavior, not as a generic product mention.
Put the Firebase write behind the hold result
An Ionic client can call a callable function or your own API route after the chart reports a hold. The server adapter should inspect the authoritative hold response, compute the amount from its line items, and create an application order. Firebase is useful for the order record, notification fan- out, and a customer’s receipt—not for manufacturing a new seat state from the browser payload.
// illustrative server adapter; validate auth and idempotency in the real handler
const hold = await seatLayer.inspectHold({ holdId: request.holdId });
const amount = hold.items.reduce((sum, item) => sum + item.price, 0);
const order = await db.orders.create({
bookingRef: request.bookingRef,
holdId: hold.id,
amount,
status: "awaiting_payment",
});
return { orderId: order.id, expiresAt: hold.expiresAt };
The snippet is intentionally an adapter shape, not a promise about a particular SeatLayer SDK
method name. The production contract is the important part: verify the hold, persist a stable
bookingRef, and make retries resolve to the same logical order. If payment fails, release or let
the hold expire according to the product’s policy; do not mark seats “available” by editing a
Firestore document.
An order document might therefore contain eventKey, holdId, bookingRef, amount, and
status, while the seat inventory remains owned by the ticketing authority. This separation lets
Firebase listeners update the customer’s UI without pretending that a listener is a reservation
lock.
When Firebase-only is enough
You probably do not need a separate seat inventory service when all of these are true:
- the map is a static preference, not scarce inventory;
- the layout is small and owned by one team;
- there is no concurrent public sale;
- the product can tolerate manual correction;
- your team explicitly owns transactions, expiry, retries, and accessibility.
The answer changes when the app sells a reusable chart to many buyers, events, organisers, or channels. At that point, keeping the inventory authority explicit is cheaper than debugging two stores that disagree after payment.
The same boundary applies to price. A Firestore cache can display a starting price or a stale “from” label, but the checkout total must come from the inspected hold. If a seat tier changes while the buyer is on the page, the honest UI is “price updated—review your seats,” followed by a fresh total; silently charging the cached value creates a payment dispute even when the seat itself was never double-booked.
Before shipping, ask one uncomfortable question: “If this phone is offline for thirty seconds and then reconnects, which system is allowed to win?” Write the answer into the reconnect path, the order schema, and the support runbook. If the answer is “the last Firestore write,” the design is not yet a booking system.
Production checklist
- Event keys are trusted product data, not arbitrary client input.
- Secrets exist only in server/runtime configuration.
- The Ionic chart mounts client-side and is destroyed on navigation.
- Hold expiry, release, conflict, and reconnect states are designed before styling.
- The server calculates payment from current hold items.
- Booking retries reuse the same host order reference.
- Webhooks are signature-verified and idempotent.
- Firebase is not presented as a second authoritative inventory writer.
- Physical-device, keyboard, screen-reader, and no-network paths are exercised.
- Simulated traces are labelled and production metrics are sourced.
The useful architecture is not “Ionic versus Firebase versus a seat SDK.” It is a set of small, honest ownership boundaries. Firebase can remain the application’s backbone while one event inventory contract decides who actually holds the seat.
Fixture and version note
We checked the SeatingChart lifecycle shape above against the current @seatlayer/js contract.
The two-buyer state trace came from a deterministic fixture using synthetic identifiers and an
in-memory authority — read its timestamps as a simulated teaching trace, not as measured API
performance.
Frequently asked
Frequently asked
Can I store seat availability in Firestore?
You can store a projection of it for rendering. What you cannot do is let Firestore be the thing that decides who wins a contested seat. If the app writes open/held/sold state to Firestore and also calls a seat API, you have two systems that can disagree at the exact moment a buyer is paying.
So what is Firebase actually good for here?
Plenty — identity, the event catalogue, cart, payment intent, and the commercial order record. That is a real and useful role. The distinction is between application state, which Firebase owns well, and inventory state, which needs a single authoritative transition owner.
What happens when two buyers tap the same seat?
One creates a hold and wins; the other gets a conflict response. That is correct behaviour, not a bug to design around. Your Ionic surface should treat the conflict as an expected state — explain it, refresh inventory, and keep the buyer's section and party-size intent intact.
Do I need Firestore listeners for a live seat map?
Listeners are useful for reflecting changes quickly, but they are not what makes the map correct. The listener technology is secondary to deciding which system may accept or reject a hold. Get the ownership boundary right first; real-time updates are a rendering concern on top of it.
Where should the seat API secret live?
In a trusted Cloud Function or server, never in the Ionic bundle. The client passes an event key and a hold reference; the trusted function inspects the hold, charges through your checkout, and books. Anything shipped to the device should be assumed readable.
Found this useful?
We build the apps described in this guide. Readymade or custom — ship in weeks.
Talk to our team →