The 100 problems, solved / Commerce, booking and payments / 052 · Design a global restaurant menu update system

Design a global restaurant menu update system

Owners edit menus, every surface worldwide shows the new price within a minute.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements

Functional

  • A restaurant owner edits a menu: items, prices, availability, photos, in several languages.
  • Every surface that shows the menu (search, maps, a food-delivery app, a partner site) shows the new version within one minute, worldwide.
  • Menus have history: an owner can see what changed and roll back.
  • Scheduled changes: a lunch menu that switches on at 11:00 local time.
  • Out of scope: ordering and payment, review moderation.

Non-functional

  • Scale: 20 million restaurants; 2 million menu edits a day; 500 million menu views a day.
  • Freshness: p99 propagation under 60 seconds; a price must never go backwards to an older version on any surface.
  • Latency: menu read under 100 ms at the edge.
  • Availability: reads 99.99 percent; edits may be briefly unavailable.
Back of the envelope

500,000,000 views / 86,400 s ≈ 5,800 reads/s average, ~30,000 reads/s at peak

2,000,000 edits / 86,400 s ≈ 23 writes/s average; the write path is tiny

A menu is about 20 KB of JSON; 20 million menus × 20 KB = 400 GB, and with ten versions kept, 4 TB. Reads outnumber writes 250 to 1, so the design is a publish pipeline feeding caches at the edge.

2. Core entities

  • Restaurant: id, region, time zone, owner accounts.
  • Menu: menu id, restaurant id, current version number, schedule rules.
  • MenuVersion: menu id, version, author, created at, full snapshot (JSON), effective from.
  • MenuItem (inside a version): item id, names by language, price by currency, availability, photo keys.

3. API

  • GET /menus/{restaurantId}?lang=te returns the current version with an ETag equal to the version number; called by every surface.
  • PUT /menus/{restaurantId} with the full menu and the base version; returns the new version or a conflict. Owner only.
  • POST /menus/{restaurantId}/schedule with a version and an effective time.
  • GET /menus/{restaurantId}/versions and POST /menus/{restaurantId}/rollback for history.

4. High-level design

ClientsEdgeServicesDataPUTnew versionmenu changedpush snapshotpurge keyGETmissOwner consoleSearch, Maps,delivery appsCDN edge cacheMenu write serviceMenu read servicePublisher workerSpanner-style DBmenus, versionsChange busRegional cachesversioned snapshots
  1. The owner saves. The write service compares the base version to the current one, writes version v+1 as an immutable snapshot, and advances the pointer in one transaction.
  2. It publishes a change event on the bus with the restaurant id and the new version number.
  3. Publisher workers in every region fetch the snapshot, write it to the regional cache under a versioned key, and issue a CDN purge for that restaurant.
  4. A surface asks the CDN. On a miss the read service serves from the regional cache, with the version number as the ETag, and the CDN stores it with a long TTL because purges, not TTLs, carry freshness.

5. Deep dives

How do we guarantee no surface ever shows an older price than it showed a minute ago?

Versions are monotonic per menu. Every cached copy carries its version, and a cache accepts a write only if the incoming version is higher. The CDN purge removes the old copy; a race where an old regional snapshot repopulates the CDN is prevented by the same rule inside the read service: it refuses to serve a version lower than the highest it has seen for that menu (a small in-memory high-water mark, refreshed from the bus). Clients send If-None-Match with their version, so a stale edge returns 304 only if its copy is at least as new.

How do we hit 60 seconds worldwide at 23 writes a second?

The write rate is small, so push, do not pull. Each region subscribes to the bus and applies changes within a few seconds. The slow part is CDN purge fan-out; purging by surrogate key per restaurant costs one call per change and completes in 5 to 20 seconds on major CDNs. Budget: transaction 100 ms, bus delivery 1 s, regional write 1 s, purge 20 s, and the first miss after purge 100 ms, well inside a minute. If the bus is delayed, a 5-minute TTL on the CDN bounds staleness anyway; say that this TTL is the safety net, not the mechanism.

What about scheduled menus and time zones?

A schedule is stored as a rule (version 42 effective 11:00 local). Converting to UTC at save time is wrong across daylight-saving changes, so store the local time and the time zone and have a scheduler service compute the next trigger per restaurant. At trigger time it performs an ordinary publish of the scheduled version, reusing the whole pipeline. Pre-warm: publishers push the scheduled snapshot under a pending key five minutes early so the switch at 11:00 is a pointer flip, not a cold load.

How are concurrent edits by two staff members handled?

Optimistic concurrency with the base version: the second save gets a conflict with a diff of what changed. For large chains editing hundreds of locations, offer a bulk endpoint that applies the same item change to many menus as independent transactions, each versioned; partial failure returns the list of menus that need a retry. This avoids a cross-menu transaction that nobody needs.

How are photos and translations handled without slowing the price path?

Photos go to object storage under immutable content-hashed keys, so a menu version only references them and the CDN never needs to purge an image. Translations are part of the version snapshot for the languages the owner provided; machine translations for the rest are generated asynchronously by a worker that writes a derived snapshot keyed by language and version, so a surface requesting Telugu gets the owner's Telugu if present, else the derived one, and the price path is never blocked on translation. The read service composes the response from the version snapshot plus the language layer, both cached, and the ETag includes both version numbers so a new translation invalidates only that language.

Partner surfaces that cannot be pushed to pull a changes feed: GET /menus/changes?since=cursor returns restaurant ids and versions in bus order with a cursor, so a partner that polls every 30 seconds stays inside the one-minute budget and never misses a change, because the cursor is a position in the durable bus log, not a timestamp.

6. What a strong answer sounds like

  • Lead with the ratio: 250 reads per write means a publish pipeline into edge caches, with purges carrying freshness and TTLs as the safety net.
  • Make versions monotonic and enforce it at every layer; that one rule answers the "never go backwards" requirement.
  • Quote the propagation budget step by step so 60 seconds is a derivation, not a hope.
  • Handle schedules in local time and pre-warm the switch.
  • Follow-ups to expect: menu items shared across a chain, partner APIs that pull instead of being pushed (give them a changes feed with a cursor), and image updates (immutable keys on object storage, so no purge needed).

02Where this question was reported