fluxo System Design
I thought it would be a fun exercise to think through fluxo’s architecture as a generic system design problem. This will be useful for describing how things work today and illustrating some of our current and anticipated challenges, especially as we scale. Selfishly, it will help me plan some work we need to do.
If you haven’t heard of us, fluxo is a coworking marketplace reimagining how we work. Check us out! This post will focus on a simplified version of fluxo, highlighting just a few key features and system components. We’ll leave a bunch of great functionality out of scope, like office discovery, venue viewings & bookings, quotes, contracting, and revenue management.
We’ll walk through functional & non-functional requirements, core entities, the API, an initial naive design, and iterate on our design to address scaling challenges.
Functional Requirements
Our marketplace supports two types of users: coworking venue operators and workers looking for somewhere to work. Let’s focus on three core fluxo features:
Venue upload: Operators should be able to upload their coworking venue to fluxo. A venue has a name, description, location, photos, and offers day passes for booking.
Search: Workers should be able to search for coworking venues by location and keywords and see relevant, rich results.
Bookings: Workers should be able book day passes offered by venues. If a day pass is available today or in the future, the user can book it and submit payment.
Non-Functional Requirements
We have a few non-functional requirements to ensure our system is performant and scales:
The system supports 1M venues and 100M users. This sets upper bounds for user traffic and data volume that we need to design against. Traffic is heavily read-skewed: the vast majority of requests are users searching and browsing, while writes (venue uploads, bookings) are comparatively rare. Let’s call it a 1000:1 read/write ratio.
Search is low latency. Users expect results to feel instant. We’ll aim for search queries returning in under 500ms at the 95th percentile. Consistency matters less here; it’s acceptable if a newly listed venue doesn’t appear right away in search, or for an availability count to be slightly stale.
Search & venue upload are highly available. When operators add venues, or workers conduct searches, the requests should always succeed. Eventual consistency is acceptable: we can tolerate a short delay before the venue appears in search results.
Booking is strongly consistent. No double-booking, ever. When a worker books a day pass, the system guarantees that two users can never simultaneously claim the last available pass for a given date. These are transactions involving real money, so we prioritize consistency over availability.
Core Entities
We need to come up with some core entities for our system that will inform our API and data model. Let’s say our core entities are: users, venues, passes, and bookings. We’ll name some of the important attributes of each entity below.
Some quick notes: A venue can offer multiple types of passes. The capacity attribute on the pass is the number of passes of that kind available per day. We might want to support other booking statuses in the future (e.g. cancelled, refunded) but the two we have above are enough for now.
API
Now we can define an API that satisfies each of our three features. HTTP REST is an easy choice for protocol: we need basic CRUD operations on well-defined resources, the system will be public-facing and browser-driven, and we have no real-time bidirectional needs that would justify websockets or SSE.
We’ll tackle this one functional requirement at a time.
Venue upload. Creating a venue is a straightforward write. Since venues include photos - big files that we don’t want to proxy through our API server - we also expose an endpoint that hands back a short-lived presigned URL to the client for uploading images directly to blob storage.
POST /venues
{ name, description, location, passes }
POST /venues/{venueId}/photos/upload-urlSearch. Search is a read over the venue catalog. We’ll support location and keyword search with query parameters. Location search needs both a location - either the user’s actual location or user input like “New York” - and a radius, while keyword search matches free text against a venue’s name and description.
GET /venues/search?location=40.678,-73.944&radius=5km&query=quiet+cafeThe endpoint returns a list of rich result objects, each containing venueId, name, address, photo URLs, and price, so the client can render full result cards in a single round-trip.
Note that in reality, fluxo search is driven mostly by location plus structured filters like capacity and amenities (”show me venues near me that fit 10 people and offer coffee”), wrapped in a smart search layer that extracts these attributes from natural language. Free text is a somewhat less important input. I chose to focus on keyword search in this post because it’s technically interesting, and excluded structured filters for simplicity.
Bookings. Booking is a write that creates the booking and initiates payment, which is completed by the client. The client sends a pass and date to the server, and the server checks that there are passes available on that date before proceeding. A more complete design would also expose a read like GET /passes so the client can validate availability before the allowing the user to attempt a booking; we’ll leave it out here to keep things simple.
POST /bookings
{ passId, date }So all together, our API is:
POST /venues
POST /venues/{venueId}/photos/upload-url
GET /venues/search?location=<>&radius=<>&query=<>
POST /bookingsOn all of these endpoints, the user is derived from the auth token, not from request bodies or query parameters. The token is a JWT carrying the userId, sent in an HttpOnly cookie. The server verifies and reads the user from the token, so authenticating a request takes no session-store lookup.
High-Level Design
We can now put together an initial design for our system. We’ll build one feature at a time, focusing on getting it to “just work”, and worry about scaling it later.
Venue upload
The basic client <-> server <-> database setup. The operator’s browser (the client) talks to a stateless API server, which reads and writes to a single Postgres database with Venue and Pass tables. A venue’s location is stored as lat/long coordinates. We’ll assume the client takes an address provided by the operator, geocodes it into coordinates (with services like Google Maps or Mapbox), and includes the result in the POST /venues request. We can leave those details out of the design. Photos belong in blob storage, not a relational database, so we’ll go with Amazon S3 and write the S3 URLs to our database.
How venue upload works:
The operator fills out the venue form and the client sends
POST /venueswith the venue’s metadata, including its coordinates.The API server validates the request and writes the
VenueandPassrows to Postgres, returning the newvenueId.For each photo, the client calls
POST /venues/{venueId}/photos/upload-url. The API server uses its S3 credentials to locally generate a short-lived presigned URL granting upload access to S3 and returns the presigned URL to the client.The client uploads each photo directly to S3 via the presigned URL.
S3 stores the photo and, on success, emits an event notification received by the API server. The server then records the photo’s destination URL in the
Venuerecord.
Search
Search doesn’t require any new infrastructure or database updates. Our existing API server handles GET /venues/search by issuing a SQL query against the same Postgres database. The query is fairly simple: we compute the distance between each venue’s coordinates and the search location, keep those within radius, and run a full-text search of query against each venue’s name and description, tokenizing both the query and the venue text and keeping the venues that contain any of the query’s terms. The location on a search request is a coordinate, taken either from the worker’s device or geocoded from a place name like “New York”.
How search works:
The worker enters a location and keyword. If the location is a place name rather than device coordinates, it’s geocoded to coordinates first, and the client sends
GET /venues/search?location=<>&radius=<>&query=<>.The API server translates this into a SQL query: keep venues within
radiusoflocationwhosenameanddescriptionmatch the query via full-text search withto_tsvector(name || ' ' || description) @@ to_tsquery(:query), where the server joins the query’s tokens with OR (e.g.quiet | cafe) so any term can match.Postgres evaluates the query and returns the matching venues.
The API server returns the results, including each venue’s metadata and photo URLs, and the client renders them.
Booking
To support bookings we need a new external dependency: a payment provider. We’ll go with Stripe. We’ll add a Booking table to our database. For now, we don’t store pass availability anywhere explicitly; we can derive it on read by subtracting the number of bookings for a given date from a pass’s capacity.
How booking works:
The worker selects a pass and date to book, and the client sends
POST /bookingswith{ passId, date }.The API server checks pass availability:
pass.capacityminus the count of bookings for(passId, date), including bothpendingandbooked. If a pass is free (availability >= 1), the server creates aBookingrecord with statuspending, calls Stripe to create a PaymentIntent, and returns thebookingIdand a client secret to the client. Otherwise, an error is returned to the client.The client renders Stripe’s Payment Element inline, using the client secret, and the worker enters payment information and completes payment; on success, the embedded Stripe UI shows the worker a confirmation.
Once Stripe processes the payment, it calls
POST /webhooks/stripeand the API server flips the booking frompendingtobooked.
Payment is an asynchronous flow because card payments don’t complete in a single synchronous server call. The client never sends card details to us; it completes payment directly on Stripe’s surface, exposed in the client with an embedded Stripe payment form, and Stripe reports the result by calling a webhook endpoint. We finalize the booking from that webhook rather than from a synchronous response, so a dropped connection can’t lose a confirmed payment.
Note that the Booking table’s userId is a foreign key to a (trivial) User table that we’ve left out of the diagram for brevity.
Scaling the Design
Our design so far meets our functional requirements for a small number of users, but things start to break down as we scale. Let’s see what changes are needed to meet each of our non-functional requirements.
Search latency
At low scale, our solution for search works well. Our database queries perform full scans on location and text, but with hundreds or even low thousands of venues, most queries complete in tens of milliseconds end-to-end. Note that our queries execute in linear time as a function of record count and venue text length. As our venue count approaches 1M+, performing distance calculations and text-tokenization & match operations per query pushes our p95 latency to hundreds of milliseconds or seconds, especially under concurrent search traffic.
We can speed up location filtering dramatically with a geospatial index. We’ll store venue location as a PostGIS geography(Point) and build a GiST index over it. A GiST index is a balanced tree structure of nested bounding boxes that allows queries to quickly filter whole regions of the map. With it, we can replace calculating the distance of every venue from the user’s requested location with ST_DWithin(location, :point, :radius), which uses the index to prune to just the venues near the location, bringing time complexity from O(n) to roughly O(log n).
Though adding the GiST index may speed up some queries dramatically, for searches over large geographic areas we’re still performing an expensive linear text filter on a large chunk of our venue table, so this index alone isn’t enough. To speed up our text search, we can add an inverted index for keywords, using a Postgres GIN index that we build over a tsvector of venue name and description, which maps each token to the list of venues that contain it. The @@ query from earlier now looks up the query’s tokens and immediately hits the matching venues instead of tokenizing and scanning every row in the venue table. This reduces time complexity from linear as a function of all venues to proportional to the count of venues containing the query terms, which should generally be much smaller.
Alternatively, we could use a search optimized database like Elasticsearch, which also implements an inverted index and is well-suited to full text searching. Elasticsearch supports geospatial queries too, so we wouldn’t have to awkwardly split searches across two datastores. However, adding it increases complexity and we’d need to set up an ingestion pipeline (e.g. with change-data-capture from Postgres) to keep it synced with our main database. At ~1M venue scale, and with relatively small text “documents” to search, Postgres indexes perform well enough and a separate system is unnecessary.
With these two indexes added, we’re in pretty good shape to meet our search latency requirement. However, search traffic is heavily skewed and we expect a relatively small set of popular queries to dominate. At peak traffic, we might flirt with the ceiling of acceptable latency and we’re doing lots of unnecessary, redundant computation over those queries.
We can add a cache for popular searches to address this. Redis is the default option, which can turn expensive queries into ~1 millisecond lookups. We use cache-aside: check the cache, and on a miss query Postgres and populate it. To ensure the cache is useful and not unrealistically dependent on users issuing queries for the exact same locations, we’ll snap coordinates to a grid or region and use that for the cache key, along with radius and query. On cache hits we’ll have to filter the matching set against the user’s precise location. We’ll set a TTL of, say, five minutes, and use least frequently used (LFU) as our eviction policy to keep the most popular queries cached. There’s some risk of staleness if a venue is removed or has its location or text edited, but these kinds of venue changes are infrequent and some staleness is acceptable and in line with our eventual consistency requirement.
With our cache and database indexes, our system now looks like this:
High-availability searches & uploads
At low scale, a single API server and single Postgres instance work just fine, supporting hundreds of venues, thousands to tens of thousands of users, and maybe tens to low hundreds of search QPS depending on hardware.
As we scale to millions of users and venues, though, search traffic can saturate the API server, exhaust database connections, or overload the database CPU, causing latency spikes and timeouts. Venue uploads and bookings are impacted too, as they share the same infrastructure as search. The two components are also single points of failure: if either goes down, the entire system is unavailable.
To address this, we can horizontally scale our API server. It’s stateless, so we can spread traffic over many instances and put them behind a load balancer. Since reads (searches) dominate our traffic, it doesn’t really make sense to scale our generic server; instead we’ll decompose it into individual search, upload, and booking services that can be scaled independently. Decoupling upload and booking from one another is somewhat less important, but it gives us additional failure isolation and independent scaling if traffic patterns diverge. We’ll put each of these services behind load balancers and set up an API gateway as the frontend for client requests. We’ll use nginx L7 load balancers for HTTP-aware routing, as we’d only use L4 instead if our system required persistent connections like websockets. We’ll use round robin as our load balancer distribution algorithm, though least-connections can work better if request durations vary significantly.
Our API layer is now in much better shape: any one service instance can fail without taking down the feature, and each service can scale independently. But we still need to sort out what to do about the single Postgres primary.
We’ll horizontally scale our database by adding read replicas, additional copies of the database across multiple servers, while maintaining a primary instance for writes (bookings). This introduces some replication lag, but that’s OK given that we only require eventualy consistency for searches.
While we’re at it, we should add pagination to our search results. Without it, at scale, our API will attempt to return huge volumes of venues for certain search queries, which poses many issues impacting latency and availability. We’ll cap each search response to a small page of results and return a cursor for the next page. The client asks for more results only when necessary. We’ll tweak our search API service in this way:
GET /venues/search?location=<>&radius=<>&query=<>&limit=20&cursor=<>With these changes, our system now looks like this:
Booking consistency
There are a couple of issues with our initial booking design. Suppose we have a day pass with a capacity of 10 and 9 bookings have been made. What happens if two users attempt to book the last pass at the same time?
Our initial design infers pass availability implicitly: we count the active bookings for (passId, date) and compare against the pass’s capacity. When multiple users book, the availability check and booking insert that follow for each are separate, non-atomic steps, so each request counts 9 bookings, concludes that one pass remains, and inserts, resulting in 11 bookings against a capacity of 10. Wrapping the two steps in a transaction doesn’t help, because the two transactions never touch a common row: each inserts its own new booking. There’s no conflict for the database to detect, so both commit and we’ve double-booked. We’ve got a race condition that violates our requirements.
We need to give the database a row that contending requests collide on. To do this, we’ll add a new core entity and database table: Availability, which is a row per (passId, date) holding a remaining count. This allows us to write a single atomic statement:
UPDATE availability
SET remaining = remaining - 1
WHERE passId = :passId AND date = :date AND remaining > 0;The database processes writes to the row one at a time, so exactly one of the two contending requests decrements the last unit; the other matches zero rows and is told the pass is sold out. We create Availability records lazily: a pass is bookable on every future date, so rather than pre-create infinite rows, a missing row for a given (passId, date) means the pass is “fully available” on that date, and the first booking for that (passId, date) upserts it with remaining = capacity - 1. The booking insert and the decrement run in one transaction so the two can never disagree.
This is an optimistic concurrency control approach: we aren’t locking a resource before acting; we attempt the write, let the database serialize it, and treat “zero rows updated” as losing the race. Optimistic concurrency control is a good choice for this setup; if we had instead chosen to lock on availability (with a SELECT FOR UPDATE statement), we’d have a database lock for possibly minutes at a time while the user submits payment information. Long-running locks expose us to deadlock risk and at scale can strain the database.
Another issue: Suppose a user initiates a booking, our server decrements pass availability, creates a pending booking, and the user changes their mind and never finalizes payment? We’re left with a dangling pending booking indefinitely, which could block another user from buying a pass. We need some mechanism for expiring pending bookings and freeing up availability.
We can handle this in the database too. We’ll give each pending booking an expiry timestamp 10 minutes after its creation - a common holding period for ticketing-style checkouts - via a new expiresAt column on the Booking table. Then we set up a scheduled job (with Postgres’s pg_cron) to periodically expire overdue pending bookings and increment remaining back in the same transaction. Our updated database looks something like:
Now that we have an expiration for pending bookings, we should probably tell the user. We’ll update our client to surface a 10-minute countdown when the user initiates a booking and prevent them from continuing once it expires. Our initial design already introduced the concept of pending bookings temporarily holding a resource, now our UI makes it explicit. However, this adds another edge case to worry about: If the user submits payment moments before the timer expires and payment doesn’t finalize until after expiration, there’s a risk that another user manages to grab the freed-up pass before the first user finalizes, which would be pretty frustrating UX. To handle this, we just need to add a safe margin to our internal expiration: we’ll enforce a 10 minute countdown in the client, but make our database expiry, say, 11 minutes.
This solution is good enough, but expiration via a scheduled pg_cron does have drawbacks. As the job runs on a fixed interval, there can be some lag between when a pending booking should have expired and when it’s actually cleaned up by the job. The expiration job is yet another system component that can fail, and failures result in even more pending bookings waiting to be cleaned up. Both of these issues can artificially restrict supply, preventing transactions that would’ve otherwise gone through.
An alternative approach is to implement inventory holds in Redis, where expiration natively happens immediately and abandoned bookings never touch the database. Unfortunately, our inventory model isn’t a great fit. Booking systems with enumerated inventory - a finite set of tickets, each a distinct item - are good candidates for Redis-style solutions, where the hold is made explicitly on the resource being booked. Our inventory is counted and unbounded: we’d have to implement holds somehwat indirectly on Availability records; a unit subtracted from a (passId, date) counter that doesn’t exist until someone books. We would need per-hold entries with read-time expiry checks and a scripted atomic check-and-hold. This is doable but unnecessarily complex, especially given that we assume booking (write) volume to be comparatively infrequent. Despite the flaws with scheduled jobs, we’re only sacrificing booking availability, not consistency.
With that, we have a pretty good system that meets all of our requirements.
Our System Today
The system we just developed is where fluxo is heading, but we’re not quite there yet. Most of the complexity we’ve gone through is only really necessary at scale. Our current stack is optimized for different priorities: rapid product iteration and go-to-market. Because of this, we’ve recently built much of our product on top of Supabase. Supabase is a backend-as-a-service (BaaS): a managed Postgres database wrapped with standard backend capabilities like an auto-generated REST API, authentication, file storage, and serverless functions. Firebase from Google is a popular alternative.
BaaS services differ quite a bit from the traditional 3-tier client/server/database model. In the traditional model, the API server that we build & operate mediates every interaction between client & database. With BaaS, there’s much less intermediation (the auto-generated API serves as a very thin layer) and authorization is managed in the database via row-level security policies (RLS). Server-side code shrinks to small edge functions for logic that can’t live in the client or database, like managing Stripe secrets or webhooks.
The advantages of a solution like Supabase are development speed and built-in scaling defaults, but at the cost of limited flexibility, a broader security surface to worry about, and vendor lock-in. The client/server/database model offers more flexibility and tighter security, but requires managing full ownership of the system and all of its complexity at scale. Our system with Supabase looks something like this:
Thankfully, a lot of what we designed in this post maps pretty cleanly to our use of Supabase, either via things we implement ourselves or features available out of the box.
Implemented by us inside Supabase:
The database schema and indexes. Our tables live in Supabase’s Postgres, and we can implement the same search optimizations: PostGIS with the GiST index on
locationand the GIN full-text index overnameanddescription.Blob storage. Venue photos go in Supabase Storage buckets (S3 under the hood), uploaded from the client via signed upload URLs.
Booking consistency. Explicit modeling of
Availability, the serializedUPDATE, and thepg_cronjob are all just Postgres.Payments. Edge functions manage our Stripe integration: one creates PaymentIntents, another receives the webhook.
Available out of the box:
API gateway and horizontal scaling. Supabase provisions a gateway for every project, and the data API and edge functions scale horizontally behind it.
Read replicas. Available as a managed add-on.
CDN for storage. Objects served from Supabase Storage come through a CDN by default.
Unfortunately, there’s no out-of-the-box equivalent of our Redis search cache. If latency becomes a problem before we outgrow Supabase, we’ll have to connect an external cache to an edge function ourselves.
We’ll likely graduate from Supabase eventually, and when that happens we’ll transition to a system that looks a lot like what we designed here.
Thanks for reading!







