Rishav Raj

A reels feed you can order from

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.

Where
Fastail
Team
Two engineers. I led the system design and built most of the frontend, working from a designer's Figma.

Worked on


What it is

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 feed rule

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.

Feed request, with the user's location Round the location to a ~1 km cell cell = geohash(lat, lng) Serviceable brands, cached per cell cell -> [brandId] hit, skip the geo query miss $geoNear on serviceArea 2dsphere index on the polygon Rank the reels from those brands orders per view, likes, watch time, recency A page of the feed
Serviceability narrows the candidate set first. Engagement only decides the order within it.

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.

Video, and why we bought it

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.

Brand picks a video API returns a signed upload preset no file touches our backend Browser uploads straight to Cloudinary past the Lambda payload limit Cloudinary transcodes bitrate ladder and thumbnail Webhook says the versions are ready the reel becomes eligible for the feed
A reel exists in the database well before it is watchable. The webhook is what flips it.

The services

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.

Consumer app Brand dashboard API Gateway Lambda, one deployable per service auth + users brands + catalog reels feed orders payments petpooja bridge notifications SQS for anything after the response
Eight services was a lot for two people. More on that at the bottom.

Orders and the Petpooja bridge

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.

Tap order on a reel Checkout price revalidated against a fresh menu pull Payment captured Order written, response returned Food brand pushed into Petpooja with our order id as the external ref Retail brand appears on the brand dashboard Status callbacks update the user
The food path is the one with a third party in it, so it is the one with the retries.

Three things took most of the work

Payments and order state

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.

Create a Razorpay order our order id stored as the receipt User pays UPI, card or netbanking browser callback is only a hint Razorpay webhook HMAC checked, replays ignored captured order confirmed, sent to the kitchen failed order cancelled, nothing charged pending UPI can sit here for minutes Polled until it settles past the timeout we cancel, and refund if it captures late
Only the webhook branch moves an order. The dashed path from the browser is ignored.

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.

created awaiting payment paid money captured accepted kitchen has it delivered payment failed kitchen rejects cancelled nothing charged refunded money returned
Two axes, not one. Money can be captured while fulfilment is still undecided.

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.

Data model

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.

Deployment

Lambda behind API Gateway, MongoDB Atlas, SQS for the work that happens after the response, and Cloudinary for video and its CDN.

Apps consumer and brand API Gateway Node on Lambda, one set per service MongoDB Atlas split per service SQS post response work Cloudinary video and its CDN
Traffic was spiky around meal times and quiet in between, which suited paying per request.

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.

What I learned


Back to home · Knowledge assistant · Flexday RBAC · Kureita