Fastail was a social commerce app for local shops and restaurants. You scroll short videos, and when you want what is in one you order it without leaving the feed.
Two sides. Local brands, restaurants and bakeries and small retail, post short videos of what they sell. Consumers scroll those videos and order from them directly. There were two of us building it, and we both worked across the stack and split whatever needed doing.
The ranking problem here is not the same as a normal short video app. A reel is only worth a slot if the brand behind it can actually serve you. A good biryani video from a kitchen that does not deliver to your area is a wasted impression and an annoyed user.
So serviceability is decided before ranking. Each brand stores its delivery area as a GeoJSON polygon with a 2dsphere index, and a feed request finds the brands covering the user's point before it looks at any engagement numbers.
Running that geo query on every request was wasteful, because two people standing on the same street get the same answer. We rounded the user location to a grid cell of roughly a kilometre and cached the serviceable brand list against the cell. Most requests start from a cache hit, and the geo query only runs when a cell is cold or a brand changes its area.
Within that set the order comes from engagement, with a decay so older reels fall away. The signal we ended up weighting highest was orders per view rather than likes. A reel can collect plenty of likes and sell nothing, which makes it entertainment, and a reel with modest likes that converts is the thing working. Likes and watch through still count, they just count for less.
We used Cloudinary instead of building a transcoding pipeline. With two engineers, running ffmpeg workers, a bitrate ladder, packaging and a CDN was not a good use of the time we had, and none of it would have been a reason anyone picked the app.
Uploads do not pass through our backend. The API mints a signed upload preset and the brand's browser sends the file straight to Cloudinary, which matters on Lambda where the request payload limit is small and a video is not. Cloudinary transcodes, cuts the thumbnail, and calls a webhook when the derived versions are ready. Only then does the reel become eligible for the feed, so nobody scrolls onto a video that is still processing.
Node services on Lambda behind API Gateway, one deployable per service. Reads go over HTTP between them. Anything that can happen after the user has their response goes on a queue, so placing an order returns as soon as the order is written and the notification, the brand ping and the analytics write happen behind it.
Restaurants already run their kitchen on a POS, and the one most of ours used was Petpooja. Asking a restaurant to watch a second tablet for our orders was never going to work, so our orders had to arrive in the system they already had open.
It runs in both directions. We pull their menu, so categories, items, variations, addons and availability, and map it into our catalog. We push orders back so the ticket prints in the kitchen like any other order, and they push status to us as it moves.
Razorpay handled payments. There are three outcomes and the third one causes all the trouble. A payment either captures, fails, or sits pending. UPI collect requests are the pending case, and they can stay that way for several minutes while somebody finds their phone. You cannot hold a spinner that long, you cannot send the order to the kitchen because it might still fail, and you cannot cancel it because it might still succeed.
The browser callback is not what we act on. It reports what the user's device thinks happened, and someone who closes the tab at the wrong moment never sends it at all. The webhook is the source of truth. Every one is checked against its HMAC signature before anything reads the body, and the handler is idempotent because Razorpay retries until it gets a 200.
Webhooks also arrive out of order, so a captured event can land after the order has already moved on. Transitions only go forward. Each state carries a rank, and an update that would move an order backwards is dropped instead of applied.
Payment state and fulfilment state are tracked separately, because a captured payment is not a confirmed order. The kitchen can still reject it. The first version collapsed the two into one field, and it produced orders that read as confirmed to the customer while the restaurant had never seen them.
When a rejection lands after capture the refund goes out automatically rather than waiting for somebody to notice. The same path covers a Petpooja push that fails for good, since a customer who has paid and has no food should not have to ask for the money back.
MongoDB, split by service rather than one shared schema. The indexes that mattered were the 2dsphere on the brand service area, a compound index on reel status and score for the feed read, and one on brand and status for the dashboard.
Reels carry a snapshot of the brand name, logo and service cells instead of joining at read time, so a page of the feed is one query. The cost is a fan out when a brand renames itself or changes its delivery area, which is rare enough to be worth it.
Lambda behind API Gateway, MongoDB Atlas, SQS for the work that happens after the response, and Cloudinary for video and its CDN.
Cold starts were the cost of that. We kept bundles small and put provisioned concurrency on the feed and order functions around peak hours, since those are the two places a delay actually gets noticed.