Scaling the Shop Home Feed Without Scaling Database Load

The Shop app’s home page is its most heavily used feature, aggregating order and tracking data from millions of merchants and dozens of carriers. As the app grew to tens of millions of users, that feed began consuming roughly 30% of Shop’s total database load, slowing down not just the feed itself but the entire application.

Standard fixes didn’t offer a path forward. The team found little room for database-level optimization, and a full code rewrite was off the table. Caching looked promising because of the feed’s usage pattern: the temporal sort means data is typically read shortly after it is written. But off-the-shelf read-through caches were a poor fit. The sheer volume of writes—Shop processed around a billion order emails in a single day in late 2020—would invalidate such a cache almost constantly, rendering it useless. Existing solutions either invalidated on every write or couldn’t guarantee users wouldn't see stale data on failure.

The engineering team instead built a custom write-through cache. After roughly six weeks of work, the solution cut database load by 15% and reduced overall app latency by about 20%.

Cache Design for Concurrent Writes

Making Shopify’s Flagship App 20% Faster in 6 Weeks Using a Novel Caching Solution

The core problem was maintaining a cached, correctly ordered list of a user’s purchases while that list undergoes concurrent updates: adding new orders, modifying order details like delivery status, and removing archived orders. A naive per-user cache key with a delete-then-write invalidation strategy wouldn't work at Shop’s scale, where multiple worker processes frequently update the same user’s feed simultaneously. Such an approach invites race conditions that could leave the cache incorrect.

The team chose a distributed Memcached cluster as the cache layer. While Shopify also uses Redis for more complex operations and queue management, Memcached’s simplicity and lower overhead made it the right tool, since the needed operations were straightforward key/value reads and writes.

To handle concurrency safely, the cache uses a secondary “pending writes” key per user that tracks the number of active database writes. Before a worker writes to the database, it increments this counter; after the write completes, it decrements it. The cached feed is only considered valid when the counter is zero, meaning a write is not in progress and the cache is in sync with the database.

A flow diagram showing the state of the Shop app before adding a caching solution
Before introducing the cache, when a user would make a request to load the home feed, the Rails application would serialy execute multiple database queries, which had high latency.
A flow diagram showing the state of the Shop app after the caching solution is introduced

After introducing the cache, when a user makes a request to load their home feed, Rails loads their home feed from the cache and makes far fewer (and much faster) database requests.

A final edge case remains: what happens if a process crashes mid-write and never decrements the counter? The counter alone can’t distinguish between an active write and an interrupted one. The solution is a separate key with a short expiry that is set before any database update. If this key is present, a write may be in progress. If it has expired while the pending writes counter is still above zero, the system knows no active write exists and it is safe to rewarm the cache.

Seamless ORM Integration and Rollout

To avoid disrupting existing code, the caching logic was implemented as a set of Active Record Concerns mixed into the relevant database records. This kept the ORM’s public API unchanged, making the new layer transparent to developers and forward-compatible with future features. When Shop Pay expanded to merchants on Google and Facebook, integrating the cache required minimal extra work.

A flow diagram showing how Shop app updates the cache
When Shop receives a shipping update from a carrier, we first mark the cache as invalid, then update the database, and then update the cache and mark it as valid.

The rollout itself was staged as a rigorous experiment. Initially, the team enabled only the cache writing and updating logic, leaving read paths untouched, to validate durability and scalability under production load. Next, they tested correctness by serving requests from both the cache and the database simultaneously, comparing results and logging any discrepancies to a dashboard. After several weeks of running this comparison and fixing the issues it surfaced, the team gained enough confidence in the cache’s consistency to enable it for all users.

Once fully deployed, the effects were immediate. Database servers saw a lighter load, overall CPU usage dropped by double digits, and GraphQL latency fell by 20%. The home feed no longer bottlenecks the rest of the application, giving users faster load times and giving developers one less scaling problem to manage.