Designing FitSynQ for Real-World Reliability
How FitSynQ keeps Ghanaian gyms running through everyday power and connectivity interruptions
Most SaaS products are designed around two assumptions that rarely get stated out loud: the lights stay on, and the internet is there when you reach for it. In Ghana, those assumptions are usually fine. Until they are not.
Ghana's grid and internet infrastructure are good enough to run modern businesses every day. However, power cuts and network cuts occur often enough to warrant deliberate product design considerations. If the product assumes constant power and fast internet during operating hours, routine interruptions quickly become operational friction for gym staff and members.
FitSynQ is a multi-tenant gym management platform, with four main components: a NestJS backend, a Next.js admin dashboard, a member mobile app, and a check-in kiosk.
In this post, I want to walk through the design decisions made across the system to keep the product useful even on imperfect days.

The dashed lines are the connections we assume can vanish: the kiosk's link to the backend, and Redis. Everything the kiosk needs to admit a member lives on the left of that boundary, on the device itself. The single idea underneath all of them is that we do not treat offline as a rare edge case to degrade into gracefully. Members showing up to the gym and checking in is one of the product's most frequent workflows, so the kiosk should not need a constant internet connection to function. We'll discuss this in more detail below.
The check-in cannot depend on the network
A member scans their phone at the kiosk. If that needs a round trip to our server, then a brief dropped connection can turn into a queue of irritated people. So the kiosk is built to admit members with no network at all.
Three things get primed on startup and refreshed on a timer while there's a connection. They're independent, so one failing doesn't take the others down with it:
// Scanner.tsx — non-blocking, fault-tolerant priming
Promise.allSettled([
fetchAndCachePublicKey(), // RSA public key → localStorage
refreshOfflinePassIfNeeded(), // short-lived kiosk authority token
refreshRoster(), // branch member snapshot → IndexedDB
]);
With those cached, the check-in workflow no longer requires an active internet connection to function as expected.
Token validation happens locally. Member QR codes are RS256 JWTs. The kiosk fetches our RSA public key once, caches the PEM, and verifies scanned tokens in-browser with jose. It carries a 300-second clock skew tolerance on purpose: a kiosk that's been offline, restarted, or running on a machine with a slightly drifting clock should not reject a member who is standing right there with a valid pass. Validation also distinguishes expired from invalid_signature from no_key, so the screen can say "open your app to refresh" instead of a useless "invalid."
The kiosk also holds an offline pass. Before it will bank check-ins on its own authority, it needs a short-lived (4h) RS256 pass with scope: 'offline_checkin', refreshed once it's within thirty minutes of expiry. That proves the device was recently authorized without having to phone home at scan time.
And the check-in gets written down before anything else happens. Accepted scans go into IndexedDB first:
// offline_checkins object store — literally commented "write-ahead log"
{ id, clientEventId, qrToken, kioskId, occurredAt,
status: 'pending', retryCount: 0, createdAt }
The member sees "You're checked in!" the moment that row lands. It's optimistic UI, but only in the display sense; the durable record already exists on the device. A unique index on clientEventId means a nervous double-scan can't double-book anyone, and a ten-minute local dedup window catches the same thing one layer up. It's the offline version of the server's own de-duplication, running with no server involved.
When connectivity comes back, a one-shot justCameOnline signal (true for exactly one render, then cleared) kicks off the sync manager. It batches pending rows into one POST /sessions/sync-offline with a 30-second timeout and applies the server's verdict per event: created or duplicate marks the row synced, error bumps its retry count, ceiling of five.
The decision I care about most in this whole flow is four lines of nothing:
} catch {
// Transport failure: leave everything 'pending'.
// Do NOT consume retry budget — flaky connectivity is not bad data.
}
A network blip leaves the queue alone. Retry budget is only ever spent on genuine server-side rejections. On a connection that is having a bad hour, spending a check-in's five retries on transient failures would quietly delete valid data, so the code refuses to do it.
Let the client own identity
None of that offline creation works if the database is the thing handing out IDs. You can't insert a GymSession on a disconnected kiosk and reconcile it later if its identity doesn't exist until a server round trip says so.
So every ID in FitSynQ is generated in application code. There is not one @default(uuid()) in the Prisma schema:
import { v7 as uuidv7 } from 'uuid';
export function generateUuid(): string { return uuidv7(); }
Two things follow from that, and the first one is the quiet keystone of everything else here. The client can mint a record's identity before the server has ever heard of it, which is exactly what clientEventId is. When the kiosk finally syncs, the server dedupes on that client-generated key (findFirst({ where: { clientEventId } })) and skips anything it's already seen. Idempotency stops being a thing you bolt on and becomes a consequence of who owns the ID.
The second is more mundane but worth having: UUID v7 is time-ordered, since the leading 48 bits are a timestamp. You get client-side generation without the B-tree index fragmentation that random UUIDs cause. No tradeoff to make.
Redis is a cache, not a crutch
Caching layers have a habit of quietly becoming hard dependencies. If the app starts throwing 500s when Redis hiccups, congratulations, you now have two things that have to be up instead of one.
At boot, the cache module doesn't take the connection object's word for it. It runs a real set/get/del round trip and compares the value that comes back. If any of that fails it returns a config with no store attached, and NestJS falls back to an in-memory cache. A Redis outage at startup makes the API slower; it doesn't make it dead. The socket is tuned to fail fast rather than hang (connectTimeout: 10s, commandTimeout: 5s, disableOfflineQueue: true) with a reconnect strategy that gives up after three attempts instead of retrying forever.
Per operation, every get swallows its error and returns undefined, which callers already treat as a cache miss and fall through to Postgres for. There's also an application-level 5-second Promise.race sitting behind that as a backstop, with a comment admitting it exists "in case Redis-level timeouts don't fire." The rule holds throughout: a cache failure, invalidation included, gets logged and ignored, never thrown. Losing the fast path should never block the correct one.
Payments need replay safety
Payments are where a temporary network problem stops being annoying and starts being a way to lose or duplicate real money. We run both cards and mobile money through Paystack, which processes the two rails behind one integration. Cards behave the way most payment code quietly assumes; mobile money doesn't, so a fair amount of the design below is about the specific places MoMo breaks those assumptions.
Anything durable goes through BullMQ. A Paystack webhook gets its 200 the moment it's persisted and enqueued, not after it's processed. The job carries attempts: 5 with exponential backoff and a jobId equal to the webhook ID, so a Paystack redelivery can't spawn a second concurrent job. Exhaust the retries and it dead-letters: the row stays processed: false with a marker for monitoring to replay it, rather than disappearing. The worker is a separate process with no HTTP server at all, which means it can crash, restart, or move independently of the API and then pick its jobs back up straight from Redis.
Idempotency is layered by consequence. The event ID is a SHA-256 of signature:eventType:payloadId, so it can't be forged predictably. Payout webhooks go further and gate on state: a "transfer failed" event only re-credits a balance if the payout is still PROCESSING. Re-crediting one that already settled would literally manufacture money on a duplicate delivery.
The rail-specific realities are encoded directly. Money is tracked in pesewas, integer minor units, end to end, with no float ever touching currency, whichever way it was paid. Mobile-money payouts are capped at GHS 10,000 per transfer with the remainder rolled into the next run, because that's a real limit we don't get to argue with. Card subscriptions auto-charge, but mobile money can't, since MoMo mandates aren't reusable the way a saved card is, so MoMo subscriptions fall back to MANUAL_RENEWAL with reminders instead of silent charges. And a failed card charge retries on a [1, 3, 7]-day backoff behind a soft grace window that keeps the subscription active with its end date extended across the whole schedule. Nobody should lose gym access because their bank had a bad Tuesday.
The scanner keeps reading when the tab isn't focused
The last one is physical, and it's a problem I should have seen coming.
The original scanner integration was a keyboard wedge: a document keydown listener watching for the characteristic burst of a barcode scan. Works fine, right up until you remember it only works while the kiosk tab has focus. On a shared reception PC, staff switch between apps and tabs all day, and scans can start landing in the wrong field or nowhere at all.
So check-in scanning moved to the Web Serial API, reading raw bytes off the USB-COM port. Serial data reaches the tab no matter which window has focus, so the kiosk keeps admitting members in the background while the same machine does five other jobs.
The hook around it is defensive in ways that only make sense once you've watched real hardware in a real reception setup. It retries the port open through the async-close race you hit on a hot re-pair. It backs off on read errors and declares the link broken after five consecutive failures rather than hot-spinning. It auto-reconnects on replug. And it falls back to the old keyboard-wedge path when the browser has no Web Serial, so a scanner someone left in HID mode still works.
Scaling past one of everything
Right now the whole backend is one API server and one worker. That's honest about where the product is: a handful of gyms, not a nationwide chain. The question worth asking is whether the design paints us into a corner when that changes, and mostly it doesn't — for the same reason everything above works.
The API is stateless. Auth is a JWT, tenant context is rebuilt per request, and the one piece of in-process state, the in-memory cache fallback, is something we already treat as disposable. So the first move at scale is the boring one: run several API instances behind a load balancer. Nothing in the request path assumes it's the only copy. The cache fallback going per-instance is the only wrinkle, and since a cache miss just falls through to Postgres, caches that disagree across instances cost latency, not correctness.
Workers are the same story, and this is where the BullMQ question comes in. BullMQ is already built to run many workers against one set of Redis-backed queues, so adding worker processes is a deploy change, not a rewrite. The idempotency we needed for reliability pays off a second time here: because every job is keyed and every handler is safe to run twice, two workers racing on the same queue can't double-charge anyone. The step after "more workers" is splitting queues by job type, putting payments on their own pool and letting notifications and report generation share another, so a Monday-morning flood of scheduled reports can't starve a payment webhook.
It's tempting to read "more load" as "time for Kafka," but that conflates two different tools. We'll keep BullMQ for jobs; Kafka only earns its place if the shape of the problem changes, not the volume. BullMQ is a job queue: do this work, retry it, back off, dead-letter it. Kafka is a durable, replayable event log that many independent consumers read at their own pace. They look similar from across the room but solve different problems up close. You don't reach for Kafka because you have more jobs; a gym platform is orders of magnitude below the throughput where BullMQ on Redis starts to strain. You reach for it when you want an event backbone, where every check-in, payment, and personal record becomes an event that several systems replay on their own: analytics, a warehouse, a materialized read model, maybe ML features later. The day we actually want that, Kafka or Redpanda is the right tool. Until then it's operational weight with no payoff, and adopting it early would be architecture theater dressed up as foresight.
The real ceiling isn't the queue anyway. It's Postgres, the way it usually is for multi-tenant SaaS. The levers there are well-worn, and we'd pull them roughly in order: read replicas so reporting and analytics stop competing with live writes, then time-partitioning on the big append-only tables (check-in sessions, exercise sets, audit logs), and eventually tenant sharding, routing organizations onto separate database clusters, if one primary stops being enough. That last one is less frightening here than it usually is, for a specific reason. IDs are minted by the client as UUID v7, globally unique with no central sequence, so moving a tenant's rows to another cluster can't cause an ID collision. The decision that made offline check-in possible turns out to make sharding possible too. Nobody planned that; it's just what owning your own identity gets you.
None of this is a roadmap with dates on it. It's a claim about order: the cheap, boring scaling moves are all available to us before any of the expensive, interesting ones become necessary, and the plan is to keep resisting the interesting ones until the problem actually asks for them.
The through-line
None of these pieces is clever on its own. IndexedDB, RS256 tokens, a Redis-backed job queue, integer money, a serial port — it's ordinary infrastructure, and a lot of teams would reach for the same parts.
What shaped the system was one rule we decided not to bend: the things a gym leans on minute to minute shouldn't stop working when the network does. Once that's non-negotiable, a surprising amount of the architecture stops being a choice. The kiosk has to own the check-in, so identity has to be minted on the client. Client-minted identity only stays sane if it's idempotent, so the server has to dedupe on a key the client controls. Redis can't be allowed to take the API down with it, so a cache miss and a cache outage have to look identical to the caller. Each decision is mostly forced by the one before it.
What I didn't expect was how often a decision made for reliability quietly paid for something else. Client-owned IDs were about surviving an offline check-in; they're also what would make tenant sharding safe years from now. The idempotency that stops a member being double-charged on a redelivered webhook is the same property that lets us run ten workers instead of one. You don't usually get to answer today's problem and next year's with the same decision. Here, we did — not by planning it, but because "make every step safe to repeat" turns out to be good advice at every scale.
So the honest summary isn't a feature list. We committed early to one uncomfortable constraint — assume the network isn't there when it matters most — and let it settle the arguments we'd otherwise still be having.