FOMO Signal
Description / Background
FOMO Signal is a contextual urgency (social-proof / scarcity) indicator surfaced on the Store Page beneath the “Book Now” action and on Restaurant Cards across Search, Suggestion, Group Landing Pages, Bitelist, Favorite, and Homepage. It is computed from a strict 6-rule priority waterfall that reads a real-time recent-booking timestamp from Redis and aggregate metrics from a ClickHouse mart (mart_restaurant_metrics), and the resulting payload is published to hh-search via Kafka so consumer-app cards stay in sync with the Store Page.
The feature targets Hungry Hub’s North Star metric (Conversion Rate) by accelerating time-to-decision on high-traffic discovery surfaces. Traffic profile is ~20k DAU across TH/SG/MY, so the design uses a hybrid caching architecture (Redis real-time + 6h batch fallback) to keep API latency under 50 ms.
Objectives
- Users can see a contextual urgency (FOMO) signal on the Restaurant Store Page beneath the “Book Now” action
- Users can see the FOMO signal on Restaurant Cards across Search, Suggestion, Group Landing Pages, Bitelist, Favorite, and Homepage (desktop)
- Users see a recent-booking signal (“A diner booked X mins/hours ago”) within 6 hours of any confirmed reservation
- Users see a daily volume signal (“X diners booked today”) when recent-booking threshold is not met
- Users see a weekly volume signal (“X diners have booked this week”) when daily threshold is not met
- Users see a popularity signal (“X people viewed this today”) on the Store Page only
- Users see an availability signal (“Plenty of seats available”) on the Store Page only
- Users never see a FOMO signal when none of the 6 rules apply (component collapses to 0 px)
- Users never see a broken / loading FOMO signal (silent fail on Redis, ClickHouse, or Kafka outage)
- Users with
prefers-reduced-motion: reducesee the static first-frame PNG instead of the animated GIF - Users see the signal in their local language (8 locales: en, th, zh, id, ms, ja, ko, ar)
- Anonymous (not logged in) users see the FOMO signal — no auth required
- Restaurant Partners benefit from increased booking velocity on their listings
- Search Card CTR improves by +3% over baseline
- Store Page → booking conversion improves by +3% over baseline (North Star)
-
60% of active restaurants trigger Rule 1, 2, or 3 within the measurement window
- Store Page API additional latency stays under 50 ms (guardrail)
- Booking confirmation is never blocked by a FOMO failure (silent fail + APM report on every code path)
- Restaurants cannot have a stale Rule 1 signal linger past the 6-hour Redis TTL (batch publish + cache invalidation enforce transition)
- Search service does not recompute the waterfall (it stores the Kafka delta as-is, returns
nullwhen stale) - Booking flow does not get any new top-level MySQL columns, no new engine, no new schema migration
- Mobile app does not get vertical-card FOMO signal on the Homepage (desktop only) to control vertical density
- Restaurant Cards do not show Rules 4 or 5 (popularity yesterday / plenty of seats) — those are Store Page semantics only
- FOMO is never published for a reservation that did not persist (Dispatcher fires only after
self.outcome = reservationguard)
Scope
| Item | Detail |
|---|---|
| Surfaces | Store Page, Restaurant Cards (Search, Suggestion, Group Landing, Bitelist, Favorite, Homepage desktop) |
| Platforms | Web (Mobile/Desktop), iOS App, Android App |
| Languages | 8 locales (en/th/zh/id/ms/ja/ko/ar) for the storefront UX copy |
| Markets | TH, SG, MY (timezone-aware per restaurant) |
| Package types | All (AYCE, Party Pack, Xperience, DIY set, Add-on) |
| Feature flag | ff_fomo_signals_v1 (Flipper) |
| Kafka flag | kafka_producer_hhsearch (existing, reused) |
Location
Store Page: rendered immediately below the “Book Now” action button (mobile + desktop). Collapses to 0 px when Rule 6 (no signal) wins.
Restaurant Cards:
- Search results, suggestion, group landing pages, Bitelist, Favorite → beneath the restaurant name.
- Homepage → vertical card layout → desktop only (mobile omitted to control vertical density).
How to set FOMO Signal on Flipper
- Enable Flipper
ff_fomo_signals_v1for the target audience (Admin accounts first, then A/B cohort). - Confirm
kafka_producer_hhsearchis on for the Kafka publish path. - Verify the ClickHouse mart
mart_restaurant_metrics(or_mvvariant in production) is reachable; verifyClickHouseHelper.should_establish_connection?returns true. - Trigger one booking for a known restaurant; confirm a
fomo_signalwithrule_id: 1appears on the Store Page within the Redis TTL window (<6 h). - Wait one batch run (≤2 h) and confirm
rule_idtransitions to 2–5 for restaurants whose booking has aged past 6 h. - Roll out per the GTM phases (Internal QA → 50/50 A/B → GA).
Sequence Diagram / Flow
Real-time path (Rule 1)
sequenceDiagram
participant U as Diner
participant W as Web/App
participant API as hh-server (Store Page API)
participant CH as ClickHouse mart
participant R as Redis
participant SV as ReservationService::Create
participant D as FomoSignal::Dispatcher
participant W1 as UpdateRedisOnConfirmWorker
participant W2 as PublishToSearchWorker
participant K as Kafka (RESTAURANTS_TOPIC)
participant HS as hh-search
Note over W,API: Subsequent Store Page load
W->>API: GET /api/v5/restaurants/:id
API->>CH: HhClickhouse::RestaurantMetrics.find_by_restaurant_id
API->>R: RedisLastBooking.read
API-->>W: { fomo_signal: { rule_id, ... } }
W->>W: fomoSignal.ts -> localized text + icon
Note over U,SV: Booking confirmation (sync or async)
U->>SV: Confirm reservation
SV->>SV: self.outcome = reservation
SV->>D: Dispatcher.new(reservation).call
D->>W1: enqueue(restaurant_id, timestamp.iso8601)
D->>W2: enqueue(restaurant_id)
D->>D: Resolver.invalidate_cache_for!
D->>D: Resolver.bust_view_cache_for!
W1->>R: RedisLastBooking.write(ts) (TTL 6h)
W2->>API: Resolver.new(...).call -> signal
W2->>K: ProducerWorker(RESTAURANTS_TOPIC, UPDATE_EVENT, id, {fomo_signal})
K->>HS: store delta
Batch path (Rules 2–3)
sequenceDiagram
participant CRON as sidekiq_scheduler (every 2h)
participant BW as FomoSignal::BatchPublishWorker
participant R as Restaurant.bookable.not_expired
participant W2 as PublishToSearchWorker
participant K as Kafka (RESTAURANTS_TOPIC)
participant HS as hh-search
CRON->>BW: perform
BW->>R: find_in_batches(batch_size: 50)
loop each restaurant
BW->>BW: Resolver.invalidate_cache_for!
BW->>W2: enqueue(restaurant_id, self.class.name)
end
W2->>W2: Resolver.new(...).call -> signal
W2->>K: ProducerWorker(UPDATE_EVENT, id, {fomo_signal})
K->>HS: store delta
Priority waterfall (single source of truth: FomoSignal::Resolver)
| Priority | Rule | Source | Threshold |
|---|---|---|---|
| 1 | Recent booking (<6 h) | RedisLastBooking (real-time) + CH last_booking_created_at (fallback) | timestamp > 6.hours.ago |
| 2 | Busy today + yesterday | CH covers_today + covers_yesterday | > 2 |
| 3 | Busy last 7 days | CH covers_last_7_days | > 10 |
| 4 | Popular yesterday (store page only) | CH restaurant_views_yesterday | > 50 |
| 5 | Plenty of seats next 7 days (store page only) | CH available_days_next_7_days | ≥ 5 |
| 6 | None | (fallthrough) | — |
surface: :store_page (default) fires Rules 1–5. surface: :search_card skips Rules 4 and 5 per PRD. Surface is encoded on the cache key so the two surfaces never share a cached payload.
ERD
No new MySQL schema. Read-only ClickHouse mart mart_restaurant_metrics (owned by Data Team). Columns consumed by the Resolver:
| Column | Type |
|---|---|
restaurant_id | Int64 |
last_booking_created_at | Nullable(DateTime64(3)) |
bookings_today / covers_today | UInt64 / Int64 |
bookings_yesterday / covers_yesterday | UInt64 / Int64 |
bookings_last_7_days / covers_last_7_days | UInt64 / Int64 |
restaurant_views_yesterday | Int64 |
has_available_seat_next_7_days | UInt8 |
available_days_next_7_days | UInt64 |
next_available_slot_at | Nullable(DateTime) |
Backend Implementation
- Single 6-rule priority waterfall lives in
FomoSignal::ResolverwithTTL = 6.hours(Rule 1 threshold, Redis TTL, result-cache TTL — same constant). FomoSignal::Dispatcheris the single trigger point, called fromReservationService::Create#executeafterself.outcome = reservation, so it fires for both sync and async booking paths.- Three workers added under
app/workers/fomo_signal/:UpdateRedisOnConfirmWorker(queue:default,unique: :until_executed) writes the Rule 1 booking timestamp to Redis and invalidates the Resolver + view caches.PublishToSearchWorker(queue:kafka_hh_producer,unique: :until_executed) resolves the signal and publishes a delta toRESTAURANTS_TOPICviaEventDrivenWorkers::HhSearch::ProducerWorker.BatchPublishWorker(queue:kafka_hh_producer,lock: :until_executed) runs every 2 h viasidekiq_schedulerand fans out onePublishToSearchWorkerper bookable, non-expired restaurant in batches of 50.
FomoSignal::RedisLastBookingwrapsRails.cachefor the Rule 1 timestamp; every write/read/delete silently fails and reports to APM — a Redis outage never blocks booking confirmation.- Read-only ClickHouse model
HhClickhouse::RestaurantMetricsover the Data Team’s mart (mart_restaurant_metricsin dev/staging,mart_restaurant_metrics_mvin production viaClickHouseHelper.mart_restaurant_metrics_table_name); bulk + single entry points. Api::V5::RestaurantSerializergainsattribute :fomo_signal; default surface:store_page, callers may passinstance_options[:fomo_surface] = :search_cardto skip Rules 4 and 5.EventDrivenServices::HhSearch::Schemas::RestaurantSchema#fomo_signal_attributeis a 3-case gate (explicit delta, slice-discard, full reindex) so non-FOMO restaurant updates (tag, picture, expiry) don’t trigger a CH + Redis round-trip.- Kafka delta is
{ id, fomo_signal: <hash|nil> }onRESTAURANTS_TOPICwithUPDATE_EVENT;hh-searchstores the delta as-is and returnsnullwhen stale/missing — the waterfall lives only on the Rails side. - Entire feature is gated by Flipper
ff_fomo_signals_v1; Kafka publish path additionally gated by existingkafka_producer_hhsearch. Both flags off → Dispatcher, workers, Resolver, and bulk path all early-return. - One-line change to the booking path in
ReservationService::Create#execute; no new MySQL columns, no new engine, no new schema migration.
PR: https://github.com/hungryhub-team/hh-server/pull/8475
Hybrid Implementation
hh-pegasus (consumer web/app):
- New
RestaurantFomoSignal.vuecomponent renders the animated GIF icon + localized text. Pure presentation; props are the camelCaseFomoSignalpayload. - New
fomoSignal.tspriority formatter resolves the localized copy fromrule_id+ counts + timestamp; handles Rule 1 dynamic time (0–59 mins → “A diner booked X mins ago”, 1–5 hours → “…hours ago”, rounded down). Restaurant.tsschema gainsFomoSignalinterface;searchRestaurantGraphql.ts,getBitelistRestaurants.ts,searchSuggestionGraphql.ts,getHomepageListGraphql.ts,getRecommendedForYouGraphql.ts, andgroupLandingRestaurantGraphql.tsall request the FOMO metric in their GraphQL queries.- FOMO signal is rendered on all restaurant card surfaces:
BitelistRestaurantCard,RestaurantCard(group card variant and desktop slider variant),MapViewModal,SearchRestaurantCard,SearchRestaurantCardWrapper,SearchRestaurantList,SuggestionRestaurantCard,SuggestionView,RestaurantSlider, andRestaurantBodyMain. - FOMO signal is rendered beneath both the desktop and mobile booking actions:
BookingPageDesktop(under the desktop Book Now button) andRestaurantBookPackage(under the package booking action on Store Page). - Analytics wiring adds
fomo_appliedandfomo_rule_idfields toselect_item/view_itemStore Page tracking viahomeEvents.ts,clickHandler.ts,trackEvents.ts, andeventsHandler.ts;RestaurantPageHybrid.astrofires FOMO view tracking. restaurant.jsonlocalized across 8 language files (en, th, zh, id, ms, ja, ko, ar) — each gains the 5-rule localized matrix entries.RestaurantCardRoot.vuestretches restaurant card roots to dynamic height so the signal does not break vertical alignment.- Card parent containers dynamically expand vertical height on narrow screens so wrapped text is never truncated.
hh-felidae (search service):
- New GraphQL
FomoSignaltype added to both puma (RestaurantAttributes,SuggestionRestaurant) and tiger (RestaurantAttributes) with fieldsruleId,coversToday,coversYesterday,coversLast7Days,restaurantViewsYesterday,maxSeatLeftNext7Days,lastBookingTimestamp. - OpenSearch index bumped: puma
restaurants_v31→restaurants_v33, tigerrestaurants_v14→restaurants_v15; newfomo_signalnested mapping (rule_id,covers_today,covers_yesterday,covers_last_7_days,restaurant_views_yesterday,max_seat_left_next_7_days,last_booking_timestamp) added. SearchQueryBuilderprojectsfomo_signalin_sourcefor both restaurant and suggestion searches.mapRestaurantHitandsearch-suggestionsmap the ES_source.fomo_signalinto the camelCase GraphQLfomoSignalpayload (returnsnullwhen source missing).- New
fomo-signal.test.tscovers complete, missing, and partial signals in restaurant + suggestion paths (including fallback “you may also like” suggestions). - Store Page renders the FOMO signal immediately below the “Book Now” action button on both mobile and desktop; collapses to 0 px height/margin when Rule 6 (no signal) wins.
- Restaurant Cards render the FOMO signal beneath the restaurant name on Search results, Suggestion, Group Landing Pages, Bitelist, and Favorite.
- Homepage vertical-card layout renders the FOMO signal on desktop only (mobile omitted to control vertical density).
- Localized copy matrix drives the icon + text per rule:
- Rule 1a (0–59 min) → 🍴 Cutlery (Red) — “A diner booked X mins ago”
- Rule 1b (1–5 h) → 🍴 Cutlery (Red) — “A diner booked X hours ago” (rounded down)
- Rule 2 → 🔥 Fire (Orange) — “X diners booked today”
- Rule 3 → 👥 People (Yellow) — “X diners have booked this week”
- Rule 4 → 👀 Eye (Green) — “X people viewed this today” (Store Page only)
- Rule 5 → 📅 Calendar (Green) — “Plenty of seats available” (Store Page only)
- Card text uses dynamic flex-wrap and wraps to a new line on smaller screens — no truncation, even to 3+ lines; card parent expands vertical height dynamically.
- Empty / Error / Timeout states fail silently: component collapses to 0 px height, no error toast, no red text. Loading state uses a subtle skeleton/shimmer that resolves instantly on payload arrival.
- Accessibility: animated GIF icons (<50 KB, 1.5–3 s cycle) honor OS-level
prefers-reduced-motion: reduceand switch to the static first-frame PNG fallback to avoid vestibular issues. - All rendering is presentation-only — anonymous (not logged in) users see the FOMO signal without any auth requirement.
- Store Page and Restaurant Cards read the same
fomoSignalpayload through the existing GraphQL / API contract; no additional client network calls beyond the existing restaurant fetch.
PR:
https://github.com/hungryhub-team/hh-pegasus/pull/3089 https://github.com/hungryhub-team/hh-felidae/pull/402
Mobile Implementation
No native iOS/Android UI changes — the FOMO signal renders on mobile via the embedded web view (same as desktop Store Page and Restaurant Cards). The Store Page and Restaurant Card components are shared across mobile web and desktop web; no separate mobile implementation is required.
PRD & Task
- PRD: FOMO Signal — https://app.clickup.com/9003122396/docs/8ca1fpw-7922/8ca1fpw-65296
- Main task: FOMO Signal: Store Page/Restaurant Card — https://app.clickup.com/t/9003122396/86d3j5muy (
CU-86d3j5muy, status: live on prod, Sprint 75) - Related backend tickets:
CU-86d3pe7ec(Waterfall Service),CU-86d3pe7er(Store Page API),CU-86d3pe7fe(Kafka Sync),CU-86d3pe7g1(Feature Flag & Fallback) - GIF assets: Google Drive — https://drive.google.com/drive/folders/14D-rljMidZlGxHACqMyGoAafXb7cIugS?usp=sharing
Design
- Figma: Store Page Update — https://www.figma.com/design/T9kSuAj3GZW7khySGNq84m/Store-Page-Update?node-id=11004-4596
- Animation spec: looping GIFs, <50 KB, 1.5–3 s cycle,
prefers-reduced-motionfallback to first-frame PNG. - Iconography: 5 icons (Cutlery/Fire/People/Eye/Calendar), each color-coded per the localization matrix above.
API Blueprint
| Method | Path | URL | Description | Payload |
|---|---|---|---|---|
| GET | /api/v5/restaurants/:id | https://{host}/api/v5/restaurants/:id | Store Page detail; returns nested fomo_signal (Rule 1–5 payload, or null) | { fomo_signal: { rule_id, covers_today, covers_yesterday, covers_last_7_days, restaurant_views_yesterday, max_seat_left_next_7_days, last_booking_timestamp } | null } |
| GET | GraphQL searchRestaurants / searchSuggestions | https://{host}/graphql | Restaurant + Suggestion list; each item carries fomoSignal (Rule 1, 2, 3, or 6 only — Rules 4–5 skipped via surface: :search_card) | { fomoSignal: { ruleId, coversToday, coversYesterday, coversLast7Days, restaurantViewsYesterday, maxSeatLeftNext7Days, lastBookingTimestamp } | null } |
| Kafka | RESTAURANTS_TOPIC UPDATE_EVENT | internal | Delta { id, fomo_signal } from PublishToSearchWorker / BatchPublishWorker | { id: Int, fomo_signal: <hash|nil> } |
New Query
ClickHouse mart (read-only, owned by Data Team):
SELECT restaurant_id, last_booking_created_at, bookings_today, covers_today,
bookings_yesterday, covers_yesterday, bookings_last_7_days,
covers_last_7_days, restaurant_views_yesterday,
has_available_seat_next_7_days, available_days_next_7_days,
next_available_slot_at
FROM mart_restaurant_metrics -- dev/staging
-- FROM mart_restaurant_metrics_mv -- production
WHERE restaurant_id IN (<ids>)
HhClickhouse::RestaurantMetrics.find_by_restaurant_ids issues a single WHERE … IN (…) query (no per-id round-trip). find_by_restaurant_id(id) adds LIMIT 1.
Resolver entry points:
# Single (Store Page API path)
FomoSignal::Resolver.new(restaurant_id: 4503, surface: :store_page).call.data
FomoSignal::Resolver.new(restaurant_id: 4503, surface: :search_card).call.data
# Bulk (Kafka batch publish path)
FomoSignal::Resolver.bulk_for([4503, 837], surface: :search_card)
DB Schema / Database Migration
No MySQL migration. The feature is fully additive:
- No new top-level columns on
reservationsor any production MySQL table. - No engine / new schema.
- New ClickHouse mart
mart_restaurant_metricsis owned by the Data Team; this PR only reads from it. - New Redis keys live under prefix
fomo:last_booking_ts:<restaurant_id>(TTL 6 h). - New
Rails.cachekeys live underfomo_signal:<restaurant_id>:<surface>(TTL 6 h).
The single production code change to the booking path is one line in app/services/reservation_service/create.rb:
self.outcome = reservation
FomoSignal::Dispatcher.new(reservation).call
This replaces the previous after_action :publish_fomo_signal_for_created_reservation in Api::V5::ReservationsController, which fired only on the sync branch and silently read the wrong @reservation from params.
Improvement:
| Feature Name | Date | What Changed | Description |
|---|---|---|---|
| FOMO Signal v1 | 2026-08-05 | Backend waterfall + Kafka sync | Initial rollout. 6-rule waterfall with hybrid Redis + CH caching. Real-time Dispatcher + 2h batch. Api::V5::RestaurantSerializer#fomo_signal surfaces the payload. |
| FOMO Signal v1 | 2026-08-05 | Hybrid search sync (felidae) | ES index v31 → v33 (puma), v14 → v15 (tiger); FomoSignal GraphQL type + mapper; new test suite. |
| FOMO Signal v1 | 2026-08-07 | Hybrid storefront (pegasus) | 16-file enhancement: RestaurantFomoSignal.vue + fomoSignal.ts formatter wired into all restaurant card variants, BookingPageDesktop, RestaurantBookPackage. 8-language localization. Analytics events for fomo_applied / fomo_rule_id. |