PETPET · Recommendations
Why I moved PETPET’s recommendations out of the API request
Every time someone opened the personalized feed, the API was doing the expensive thinking before it could return a single page. I moved that work to background workers—and kept the product details live.
October 30, 2026 · 4 min read
The feed was making every request do the same hard work
The personalized feed has a simple job from a shopper’s point of view: show a useful set of products. The first version did the selection and ranking while handling the request. It had to find candidates for that user, score them, and then fetch enough product information to build the cards.
That approach was straightforward, but the request carried work that did not need to happen every time the screen opened. It also meant product-card lookups and ranking were competing for time on the same path. In the production logs I checked, a personalized recommendation request was around 700 ms before the change. That was the point where I started looking at which work could happen earlier.
I changed when the ranking happens
Instead of rebuilding a user’s ranked list inside the API call, background workers now prepare recommendation data ahead of time and publish it as a read model. When the app asks for a page, the API reads the prepared ordering, takes the requested slice, and loads the product details for that page in bounded batches.
The distinction matters: I moved the reusable ranking work, not the responsibility for returning accurate product cards. Product visibility and availability can change after a recommendation was prepared, so the serving path checks current product data before returning it. A ranked candidate is a suggestion—not permission to show a product that is no longer eligible.
I also kept recommendation preparation separate from serving. A refreshed result can be validated before it becomes the version the API reads. That gives the worker room to do heavier work without turning the mobile request into a job runner.
Related products are a different problem: start with an item, find similar candidates, then rank them. The user-specific feed was where moving the repeated ranking work out of the request made the clearest difference.
Why not cache the finished product cards?
Because the ranking and the product card age at different speeds. The ordering can be reused for a while, but prices, stock, visibility, and other card fields are live facts. Caching the whole response would make the API faster by making it easier to return an outdated answer.
The read model stores the part that is expensive to prepare—the ordered recommendation data. The API then hydrates only the page the user requested. That keeps the request small while preserving a chance to apply current product rules at the last responsible moment.
What changed in practice
Looking at the production logs before and after the redesign, the personalized recommendation endpoint moved from roughly 700 ms to around 300 ms. Those are approximate observations from the live request path, not numbers from a synthetic benchmark. The practical change was that the API no longer had to assemble and rank the entire personalized result on every request.
The part I’d carry into the next system
The useful optimization was not “add a cache.” It was noticing that ranking could be prepared before the shopper asked for it, then keeping the request focused on serving a page. I still had to decide which facts were safe to prepare and which ones needed a fresh check.
That split—prepare what is repeatable, verify what changes—made the recommendation path lighter without making freshness an afterthought.
For the full walkthrough—from event weighting and ALS candidates to recent-intent updates and live product checks—I wrote a separate, deeper design article.