WiiHop · deals platform · client build · TypeScript
Building WiiHop, a live deals app, and scaling it for a crowd
WiiHop is an app for live deals at bars and restaurants. A venue posts a short deal, people nearby check in, and they earn points for it. I've been the only engineer on it since July and built it from scratch: a guest app, a venue dashboard and an admin panel, plus AI agents that help venues run. Below is what broke as it grew, and how I fixed each problem.
Role
Sole engineer, for a client
Stack
React, Node, Express, Prisma
Data
PostgreSQL, Redis, Bull
So far
722 commits since July 2026
What's in WiiHop
WiiHop is really three apps: one for guests, one for venues and one for admins. It's live, with over 70 features.
Part
What's in it
Guests
A map of live deals, check-ins with points and streaks, leaderboards by city and week, a live radar of how busy places are, game-day watch parties, giveaways, and naming your own price on a dish.
Venues
Deals, menu, bookings and catering. Menu import from their website or a PDF. A staff time clock with a PIN and a photo, QR codes and analytics.
Admins
Approve venues, launch cities, and switch any feature off for everyone at once.
AI agents
Each venue gets a team of agents for its money, its stock and supplier orders, and replying to reviews. Our own staff have agents too, which find new venues and draft emails to them.
Data
Happy hours collected from venue websites: the top 50 places in each zip code, across 19 cities.
Fixing a race condition in Postgres check-ins
When you check in at a deal for the first time, you get a one-time bonus. If your phone sends two check-ins a few milliseconds apart, only one of them should get that bonus.
To make sure of that, the first version ran every check-in inside a Serializable transaction. That's the strictest mode Postgres has: if two transactions might interfere with each other, it cancels one of them. My code then retried the cancelled one up to three times.
Before launch I load-tested it with k6, a tool that pretends to be lots of users at once. With 200 of them checking in, 838 of 1,654 check-ins failed.
The reason surprised me. Most of the cancelled check-ins weren't one person tapping twice. They were different people checking in at the same deal. Postgres saw both of them reading the same deal and treated it as a possible conflict, so it cancelled one. The retries only added more load, so the busier a venue got, the more check-ins failed.
How I got there
Explore
First idea: keep Serializable and retry more
Realise
But the clashes were between different people, and repeat visits are allowed
Insight
So protect only the bonus, not the whole check-in
Let the database refuse a second bonus, instead of making every check-in fight.
How a check-in works now
One bonus per person per deal, enforced by the database
I added a unique index, which is a database rule that stops the same row being saved twice. It only covers the first-visit bonus, so normal check-in points still work on repeat visits.
backend/prisma/migrations
Back to Postgres's normal mode
Since the index now stops double bonuses, Serializable isn't needed. In the default mode, Read Committed, two different people no longer get in each other's way.
Try to save the bonus, and see if it worked
The code simply tries to insert the bonus with INSERT ... ON CONFLICT DO NOTHING. If a row comes back, this request got the bonus. If nothing comes back, another request already got it. There's no error and nothing to retry.
backend/src/routes/user.routes.ts
Reply first, do the slow parts after
Streaks, rewards and the scratch card used to run before the app got a reply. Now they run in a background queue, and the app gets the result over its live connection a moment later.
The partial unique index
CREATE UNIQUE INDEX "UserPointEvent_first_checkin_per_deal_key"
ON "UserPointEvent" ("userId", "dealId")
WHERE "pointEventTypeId" = 2 AND "dealId" IS NOT NULL;One mistake I made: the first time I ran the fix, every single check-in failed. I had tested the SQL by hand with the number typed straight in. The real code sent that number as a parameter, and Postgres can't match a parameter against this kind of index. Since then, a test script goes through the real endpoint and checks that 25 check-ins at once from one person give exactly one bonus.
A geo search bug that dropped the nearest deal
The deals map was leaving out nearby deals. The query grabbed the 1,000 newest deals first and only then sorted them by distance. So a deal 200m from you could be dropped, while one 20km away made the list. During the load test it hit that 1,000 limit 1,384 times in nine minutes.
It also cached the full result of every search, with all the deal details. One search came to 1.3MB, so Redis kept running out of room and throwing other entries away.
Now it sorts by distance first and only then cuts the list down to one page. The cache stores just each deal's id and distance, and the full details are loaded only for the deals on screen. A spot with 1,197 deals nearby now gets all 1,197.
1.3MB → 41KB
size of the biggest cache entry
2,901 → 0
cache entries Redis had to throw away
45% → 2%
Redis CPU use
Source · k6, 200 virtual users, nine minutes.
Fixing a cache stampede in Redis
Cached results last 60 seconds. When one expired, everybody on the deals page found it missing at the same moment, and every one of them ran the same heavy query. With 300 people on the page, the database would do nothing for 59 seconds and then get 300 identical queries at once.
Now only the first request runs the query, and everyone else waits for that answer. In testing, 50 requests at the same moment led to just one query. If that query fails, the next request tries again, so one failure doesn't get stuck in the cache.
An analytics page that ran 11,000 database queries
The venue dashboard fetched its numbers one day at a time, with a separate database query for each day. A single deal-performance page could end up running about 11,000 queries, one after another.
The guest app had a similar problem. A page of deal cards sent 20 to 60 requests while it loaded, which was enough to trip the app's own rate limiter.
Now each chart gets its data in one grouped query. The cards only load extra details when you actually see them, and the app no longer re-fetches everything each time you come back to the tab.
Moving scheduled jobs to a Redis queue with Bull
Jobs like the monthly points reset were kept in a timer inside one server's memory. If the server restarted, they were lost, and if we ran two servers, every job would run twice. One daily job had also been drifting: it was set for 10:00, but ran at 10:00, then midnight, then 14 hours after that.
I moved them into Bull, a job queue that keeps its jobs in Redis. Jobs now survive restarts, each kind of job has its own queue, and Redis makes sure a scheduled job runs only once, however many servers there are.
Graceful shutdown, so deploys don't drop requests
Deploying stopped the server straight away, even in the middle of requests, and any job that was running got stuck until it timed out. Some requests could also fail with a 502 error, because Node closed idle connections sooner than nginx, the server in front of it, expected.
Now, when the server is told to stop, it stops taking new requests, finishes the ones in progress, closes the queues and the database connection, and then exits, all within 15 seconds. Node also keeps idle connections open a little longer than nginx does, so those 502s don't happen any more.
Guardrails for AI agents: spend, time and approvals
The AI agents can spend money, change prices and email venue owners, so one bad run could do real damage. Each agent has hard limits written in code, not just instructions in its prompt.
Limit
How it works
Spend
Each agent can spend at most $5 a day on AI. Every run checks the day's spending before it starts, and records its tokens and cost.
Time
A run stops itself after two minutes, and an admin can kill it at any point.
Changes
Nothing an agent wants to change goes live until an admin approves it. Requests nobody answers expire after 7 days, and every step is logged.
Emails
The model only writes the wording of an email. Every number in it has to be one the agent was given. If the model makes one up, the email goes back to be rewritten.
Fixes I decided against
Each of these looked like the obvious fix, but would have been wrong here.
The obvious fix
Why I didn't do it
Allow only one check-in per person per deal
Coming back to the same place is the whole point of the app. This would have blocked every repeat visit.
Switch the map to PostGIS, a Postgres add-on for maps
Measuring distance was never the problem. The problem was that the list got cut down before it was sorted.
Run the after-check-in steps at the same time
That would hold four database connections at once instead of one, and running out of connections is exactly what goes wrong under a crowd.
Load testing with k6: three runs of the same test
Run
What happened
1. Before any fixes
838 requests failed with server errors, and the slow check-ins took 16.3 seconds.
2. After the check-in fix
No more errors, but it was still slow, handling only 31.7 requests a second. The deals map was now holding everything up.
3. After fixing the map too
It handled 72.9 requests a second, and all 39,502 checks passed.
p95 latency for each endpoint, before and after
p95 is how long 95 out of 100 requests took at most.
Before
After
Server errors
838
0
Requests per second
29.2/s
72.9/s
Check-in p95
16.3s
603ms
Deals map p95
5.79s
325ms
Leaderboard p95
5.23s
240ms
Deal page p95
4.05s
180ms
Source · k6, 200 virtual users, same data every run.
Ready to scale past one server
What I did
Why
Live updates go through Redis
If a reward is created on one server, it still reaches a user who's connected to a different server.
A separate queue for each kind of job
When all jobs shared one queue, a worker sometimes picked up a job it didn't know how to run, and the job failed.
Redis only throws away keys that have an expiry
The cache and the job queue share one Redis. Cache entries expire and jobs don't, so when memory runs low, Redis drops cache, never a waiting job.
Screenshots





