Yohop · deals platform · client build · TypeScript
The endpoint a crowd hits hardest could not survive a crowd
Yohop is a live deals app: venues post time-limited offers, people nearby check in and earn points. Before launch I load-tested it. Over half of all check-ins failed. This is how the same test came to pass with zero errors.
Role
Sole engineer, for a client
Stack
Node, Express, Prisma
Data
PostgreSQL, Redis, Bull
So far
615 commits since July 2026
A night out, run as a game
Yohop is three products in one. Guests find what is on near them tonight and get rewarded for going. Venues get a full back office for deals, menus, events and loyalty. Admins run cities, approvals and every feature switch.
It is live, with over 70 features finished across those three sides.
For guests
Feature
What it does
Deals near me
A map and list of live deals with real distances, spots left and the deal itself on every pin. Happy hours show as live only during their hours.
Check in and earn
Checking in at a venue earns points, streaks and rewards, and can reveal a surprise, unlock a secret menu or pay out a birthday treat.
Live Radar
Venues ranked by what is happening there this hour, rising and falling like a stock ticker, with the vibe, the crowd and the deals left.
Leaderboards
Top hoppers, longest streaks, top places and more, weekly, monthly and all time, by city.
Meet people
Wingmen to go out with, Table Talk seats at themed tables, speed dating and networking rounds, and live chat for guests at the same venue.
Events and tickets
Weekly and one-off events on each venue's calendar, tickets bought online, and staff check-in at the door.
Wallet and referrals
Coins and rewards in one wallet, invite-a-friend rewards, and bounty deals like bring three friends.
Name your price
Guests who checked in can offer a price for a dish. Inside the venue's range, it becomes a code good until midnight.
For venues
Feature
What it does
Venue dashboard
Deals, menu, bookings, analytics and a setup guide that shows what is still missing.
Every kind of deal
Standard, daily, happy hour, hidden, BOGO and redeem-now deals, plus check-in games and giveaways.
AI menu import
Point it at the venue's website and a model reads the menu and happy hour pages, quoting every dish and price from the source.
Loyalty and memberships
A loyalty program, members-only items, and alerts when regulars head elsewhere.
TV displays
Screens in the venue showing live deals, leaderboards and speed round timers.
Catering, services and food trucks
Catering orders, bookable services and a truck's schedule, all from the same account.
For admins
Feature
What it does
Approvals and cities
Approve venues, launch new cities and see which cities people are asking for.
Feature switches
Turn any feature on or off per venue, per city or everywhere.
Moderation and analytics
Review reported photos and profiles, and see performance by city.
A busy venue is a crowd checking in
Yohop has two sides. Venues post food and drink deals that run for a few hours. People nearby find them on a map, check in, and earn points, streaks and a place on a leaderboard. The first check-in at a deal also earns a one-time bonus.
That bonus is the hard part. Two requests from the same person, a few milliseconds apart, must not both decide “this is your first visit” and pay it twice. The first version guarded it the textbook way, with a Serializable transaction and a retry on conflict.
Before launch I wrote a load test with k6: 200 virtual users against a seeded database of 2,000 venues, 6,000 deals and 5,000 people, crowded round a few cities the way real traffic would be.
Still open
838 of 1,654 check-ins returned a 500. Every one of them was the same error: gave up after 3 serialization failures.
How I got there
Three passes, in the order they happened.
Explore
Keep Serializable, make it try harder
Realise
Strangers were colliding, and repeat visits are a feature
Insight
Constrain the bonus, not the visit
Make the database say no once, instead of refereeing every request.
Why the retries never stood a chance
Serializable does not stop a race by making requests wait. It lets them run, notices when two could have seen each other, aborts one and expects you to try again. That works when collisions are rare. On a popular deal they are not rare.
Worse, most of the collisions had nothing to do with the bonus. Under Serializable, the query “has this person checked in here before” is a predicate read, so two different people checking in at the same deal conflicted with each other. The busier a venue got, the more of its own customers it failed.
An earlier audit had suggested a unique constraint on check-ins per person per deal. That would have broken the product: checking in again at the same deal is allowed, and each visit earns points. Only the bonus is once per deal.
One check-in, after the fix
The bonus moved from a rule the transaction had to enforce to a row the database refuses to write twice. Everything slow moved off the request.
A partial unique index, on the bonus only
The constraint sits on the points ledger, and only on rows of the first-visit bonus type. Ordinary check-in points for the same person and deal stay unconstrained, so repeat visits still work.
backend/prisma/migrations
Drop to Read Committed
With the index doing the guarding, the transaction no longer needs Serializable. Two different people never touch the same index entry, so they stop contending at all.
The insert is the test
INSERT … ON CONFLICT DO NOTHING RETURNING id hands back a row only to the request that won. “Did I win” and “record that I won” are one atomic step, and nothing ever throws.
backend/src/routes/user.routes.ts
Answer with the points
The response goes back as soon as the write is durable. The streak, venue rewards, lottery entry and scratch card used to run first, holding a pooled connection for six or more round trips.
The rest on the queue, back over the socket
Those four steps now run as a queued job and the result arrives on the WebSocket the app already had. Measured on the dev stack: 316ms to respond, the socket event 74ms behind it.
The insert that is also the check
Raw SQL on purpose. Postgres marks a transaction as aborted the moment any statement errors, and Prisma does not wrap single queries in savepoints, so catching a unique violation would leave the rest of the transaction unusable. ON CONFLICT never raises.
const bonusRows = await tx.$queryRaw<Array<{ id: number }>>`
INSERT INTO "UserPointEvent"
("userId", "dealId", "pointEventTypeId", "points", "createdAt")
VALUES
(${userId}, ${deal.id}, ${POINT_EVENT_TYPES.FIRST_CHECKIN_DEAL},
${firstCheckInBonus}, NOW())
ON CONFLICT ("userId", "dealId")
WHERE "pointEventTypeId" = ${FIRST_CHECKIN_TYPE_LITERAL} AND "dealId" IS NOT NULL
DO NOTHING
RETURNING "id"
`;
if (bonusRows.length > 0) {
// this request won the race; it is the only one that ever will
}The first version of this shipped a bug into its own verification run. The type id in the ON CONFLICT clause was a bound parameter, and Postgres matches that clause against the index at plan time, where a parameter is opaque. Every check-in failed with 42P10, which reads as if the index is missing. It got through because I had tested the statement by hand in psql, with a literal. A script now drives the real HTTP endpoint instead: 25 concurrent check-ins by one person must produce exactly one bonus.
The second finding: megabyte cache entries
With check-ins fixed, the test pointed at the map. The “deals near me” endpoint cached its whole hydrated result, every deal with every relation, before cutting it to a page. Single cache entries reached 1.31MB. A 384MB Redis held about 500 of them and evicted 2,901 keys in one nine-minute run, spending 45% of its CPU on eviction churn.
The fix was to split the endpoint in two. A narrow scan picks candidates with only what decides membership and order: an id, the venue's coordinates, the deal type. The cache stores just the ordered list of ids and distances. Only the page the app asked for is loaded in full.
It also fixed a correctness bug. The old query cut the list to 1,000 rows, newest first, before measuring any distance, so a deal 200m away could be dropped for a newer one 20km away. The test hit that cap 1,384 times. Now the exact distance sort runs over everything in the area before anything is cut.
The cache, before and after
Before
After
Largest cache entry
1,310,856 B
41,096 B
Keys evicted per run
2,901
0
Redis CPU
45%
2%
API memory
610MB
194MB
Source · k6 run, 200 virtual users, same seeded data both times.
The audit had called for PostGIS here. It was not needed. The bug was never that distance was computed in JavaScript; it was that rows were cut before they were sorted. A bounding box in SQL narrows the candidates and an exact distance sort finishes the job.
The same test, passing
Same harness, same data, same 200 virtual users. The original run, and the run after both fixes.
838 → 0
server errors
39,502 of 39,502 checks passed
2.3x
throughput
29.2/s to 72.9/s
27x
faster check-ins at p95
16.3s to 603ms
Source · k6 load test, 200 virtual users, same harness and data before and after.
Every endpoint at p95
Original
Now
Check-in
16.30s
603ms
Deals near me
5.79s
325ms
Leaderboard
5.23s
240ms
Deal detail
4.05s
180ms
Failed requests
5.30%
0.00%
Source · Between 18x and 27x faster at p95, with zero errors.
Getting ready for a second server
Everything runs as Docker containers on one server today, but nothing assumes it will stay that way. One environment variable decides what a container is: the web role serves HTTP and WebSockets and can run as many copies as needed; the worker role owns the scheduled jobs and runs exactly once.
A few smaller decisions came out of making that true.
Decision
What it prevents
Socket.IO on a Redis adapter
A reward pushed from one API copy reaches a person whose socket is held by another.
Scheduled jobs deduped in Redis
Scaling the worker past one copy can never fire the monthly points reset twice. Before, only deploy discipline stood in the way.
One queue per job type, not one shared queue
Bull hands out the next job before it looks at the name, so on a shared queue each tier picked up jobs it had no handler for and failed them. It half-worked in testing. Separate queues make that impossible.
Concurrent cache misses share one load
A cache stampede: 300 people all missing an expired entry at once and running the same query 300 times. Deliberately per process, so it becomes one query per copy, without a distributed lock to get wrong.
Redis evicts only keys that expire
The same Redis holds cache and queued jobs. The default policy would happily evict a queued job to make room for cache, and that work would just be gone.
Graceful shutdown with a 15 second cap
Every deploy used to kill requests in flight and strand running jobs until they timed out.
What Yohop came down to:
01
Measure before launch
Every fix on this page came from a load test that failed first. The audit before it had guessed wrong about two of them.
02
Let the database say no
A partial unique index turned a race the code kept losing into an insert that simply returns nothing.
03
Cache the decision, not the data
Storing ids and distances instead of full deals cut entries 32 times and fixed a correctness bug on the way.