How I built a grocery price comparison for 27 Canadian store chains on a CA$18/month server
PriceBite is a free Android app that answers one question: where is this grocery item cheapest near me this week. Type "milk", see the same 4 L bag at every store around you, cheapest first. Add your whole list and it tells you which store is cheapest for the lot. No account, English and French, Canada only.
It runs on one Azure App Service B1 box: 1 core, 1.75 GB of RAM, about CA$18 a month. That number is the design constraint, and most of what follows is a consequence of it.
What it covers
27 store chains and 3,143 store locations, from Loblaws, No Frills and Real Canadian Superstore through Sobeys, FreshCo, Safeway, Save-On-Foods, IGA, Giant Tiger and the regional banners in between. Store locations are a 594 KB JSON file shipped as an embedded resource, loaded once, with banner, province and city strings interned so the list costs about a megabyte less than it otherwise would.
Three chains are not covered yet: Metro, Food Basics and Super C. Quebec shoppers notice this in the first minute, so the app says so instead of pretending.
Where the prices come from
From the stores' own public websites and weekly flyers, the same pages a shopper opens in a browser. Nothing logs in, nothing uses an account, and every chain is read slowly: requests are staggered, results are cached for 15 minutes, and a chain that answers with an error is left alone for a while rather than retried. The HTTP clients for the HTML banners deliberately have no retry policy at all. On a one-core box a retry storm is the fastest way to fall over, and it is also rude to the site on the other end.
A chain can be switched off with one configuration setting. The backend stops reading it and stops serving it on the next restart, and the apps in the field hide it within five minutes through a status endpoint. That switch exists so that if a retailer ever asks to be left out, the answer is "done" and not "we will get to it".
Backend
.NET 8 Minimal API. The composition root is 373 lines and contains no endpoint bodies; the 21 endpoints live in eight files by area (search, deals, basket, stores, alerts, history, reports, system). Every chain is a base service plus sealed per-banner subclasses, because the chains in one corporate family share a site engine and differ only in hostnames and store IDs.
The database is SQLite in WAL mode, through EF Core. On a single box with one writer that is not a compromise, it is the right tool. Writes go through a gate so the collectors never fight each other for the file, and price snapshots are saved through a bounded queue so a burst of results cannot balloon memory.
Price history is kept for 45 days. It used to be kept forever, and the file reached 1.58 GB before anyone noticed; a nightly backup copies the whole file to blob storage first, and on a 10 GB disk that was a slow-motion outage. Forty-five days is enough for "is this a real sale or the regular price", which is the question people actually ask.
Grouping is the hard part. The same product has a different name at every chain, and a "2% Milk 4 L" at one store is "Milk Partly Skimmed 2% M.F. 4L" at another. A grouping service normalises brand, name and size into a key and merges across chains per response. Getting it wrong in one direction shows the same product twice; wrong in the other direction merges a 1 L with a 4 L, which is worse. The basket screen is a second pass over the same data: for each store, price every item on the list, then rank stores by total, with a rule for the case where no single store carries everything.
Frontend
Expo SDK 54, React Native, expo-router, Zustand persisted to AsyncStorage, NativeWind, strict TypeScript. Every user-facing string exists in English and French, enforced by tests, because a Quebec listing with missing French is not a listing.
Updates that only touch JavaScript ship over the air in minutes. Anything native goes through a Play release. The over-the-air channel is keyed to the app's version string, which is a footgun: bumping the version for a JavaScript-only change strands every installed device on the old channel. There is a rule in the repo about it and a memory of learning it the hard way.
App size was 127 MB at one point. Two changes brought it to 52 MB: shipping one ABI per device instead of packaging all four, and building without the dev client. Both are configuration, and both are the kind of thing that never regresses only because a document records why the configuration looks the way it does.
Tests
830 backend unit tests and 1,274 React Native tests across 110 suites, plus 16 end-to-end specs that drive the real APK on an Android emulator with Appium. The end-to-end specs select by test ID, never by text, because there are two languages. Live tests that hit real store sites are tagged and excluded from CI.
One lesson from the end-to-end suite: a build is a snapshot of the code at the moment it started, not of the repository. The suite once sat unrunnable for six days because every available binary predated the test IDs it looked for, and nothing said so. Now the build bundle carries its commit time and the test runner refuses a binary older than the specs.
No comments
The codebase has zero code comments, by rule. The reasoning behind a non-obvious guard goes in the test name that would fail without it, in the commit message, and for four areas that kept getting "simplified" back into bugs, in a short invariants document that you read before touching that area. It sounds austere. In practice it means a comment can never be stale, because there are none, and the explanation lives next to the thing that proves it.
Money
The B1 box is the only paid resource. Two Azure budgets guard it: a soft cap that emails at 80% and 100%, and a hard cap that triggers an action group. Normal run-rate is CA$16 to 19 a month. The app shows one small banner ad and has no accounts, no subscriptions and no paid tier.
What is next
iOS, when the Android version has earned it. Better coverage of the three missing chains if a permitted path appears. And more of the thing the data is uniquely good for: a weekly "cheapest store for a 24-item basket" table per city, which is what this app can produce and a flyer cannot.
Prices come from stores' public websites and flyers and may differ in store. PriceBite is not affiliated with any grocery chain.
Android: https://play.google.com/store/apps/details?id=com.pricebite.app&referrer=utm_source%3Dhn%26utm_medium%3Dshowhn Site: https://pricebiteapp.com
Pavlo, Vancouver. Questions welcome in the comments or at getpricebite@gmail.com.