Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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: reduce see 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 null when 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 = reservation guard)

Scope

ItemDetail
SurfacesStore Page, Restaurant Cards (Search, Suggestion, Group Landing, Bitelist, Favorite, Homepage desktop)
PlatformsWeb (Mobile/Desktop), iOS App, Android App
Languages8 locales (en/th/zh/id/ms/ja/ko/ar) for the storefront UX copy
MarketsTH, SG, MY (timezone-aware per restaurant)
Package typesAll (AYCE, Party Pack, Xperience, DIY set, Add-on)
Feature flagff_fomo_signals_v1 (Flipper)
Kafka flagkafka_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

  1. Enable Flipper ff_fomo_signals_v1 for the target audience (Admin accounts first, then A/B cohort).
  2. Confirm kafka_producer_hhsearch is on for the Kafka publish path.
  3. Verify the ClickHouse mart mart_restaurant_metrics (or _mv variant in production) is reachable; verify ClickHouseHelper.should_establish_connection? returns true.
  4. Trigger one booking for a known restaurant; confirm a fomo_signal with rule_id: 1 appears on the Store Page within the Redis TTL window (<6 h).
  5. Wait one batch run (≤2 h) and confirm rule_id transitions to 2–5 for restaurants whose booking has aged past 6 h.
  6. 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)

PriorityRuleSourceThreshold
1Recent booking (<6 h)RedisLastBooking (real-time) + CH last_booking_created_at (fallback)timestamp > 6.hours.ago
2Busy today + yesterdayCH covers_today + covers_yesterday> 2
3Busy last 7 daysCH covers_last_7_days> 10
4Popular yesterday (store page only)CH restaurant_views_yesterday> 50
5Plenty of seats next 7 days (store page only)CH available_days_next_7_days≥ 5
6None(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:

ColumnType
restaurant_idInt64
last_booking_created_atNullable(DateTime64(3))
bookings_today / covers_todayUInt64 / Int64
bookings_yesterday / covers_yesterdayUInt64 / Int64
bookings_last_7_days / covers_last_7_daysUInt64 / Int64
restaurant_views_yesterdayInt64
has_available_seat_next_7_daysUInt8
available_days_next_7_daysUInt64
next_available_slot_atNullable(DateTime)

Backend Implementation

  • Single 6-rule priority waterfall lives in FomoSignal::Resolver with TTL = 6.hours (Rule 1 threshold, Redis TTL, result-cache TTL — same constant).
  • FomoSignal::Dispatcher is the single trigger point, called from ReservationService::Create#execute after self.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 to RESTAURANTS_TOPIC via EventDrivenWorkers::HhSearch::ProducerWorker.
    • BatchPublishWorker (queue :kafka_hh_producer, lock: :until_executed) runs every 2 h via sidekiq_scheduler and fans out one PublishToSearchWorker per bookable, non-expired restaurant in batches of 50.
  • FomoSignal::RedisLastBooking wraps Rails.cache for 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::RestaurantMetrics over the Data Team’s mart (mart_restaurant_metrics in dev/staging, mart_restaurant_metrics_mv in production via ClickHouseHelper.mart_restaurant_metrics_table_name); bulk + single entry points.
  • Api::V5::RestaurantSerializer gains attribute :fomo_signal; default surface :store_page, callers may pass instance_options[:fomo_surface] = :search_card to skip Rules 4 and 5.
  • EventDrivenServices::HhSearch::Schemas::RestaurantSchema#fomo_signal_attribute is 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> } on RESTAURANTS_TOPIC with UPDATE_EVENT; hh-search stores the delta as-is and returns null when 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 existing kafka_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.vue component renders the animated GIF icon + localized text. Pure presentation; props are the camelCase FomoSignal payload.
  • New fomoSignal.ts priority formatter resolves the localized copy from rule_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.ts schema gains FomoSignal interface; searchRestaurantGraphql.ts, getBitelistRestaurants.ts, searchSuggestionGraphql.ts, getHomepageListGraphql.ts, getRecommendedForYouGraphql.ts, and groupLandingRestaurantGraphql.ts all 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, and RestaurantBodyMain.
  • FOMO signal is rendered beneath both the desktop and mobile booking actions: BookingPageDesktop (under the desktop Book Now button) and RestaurantBookPackage (under the package booking action on Store Page).
  • Analytics wiring adds fomo_applied and fomo_rule_id fields to select_item / view_item Store Page tracking via homeEvents.ts, clickHandler.ts, trackEvents.ts, and eventsHandler.ts; RestaurantPageHybrid.astro fires FOMO view tracking.
  • restaurant.json localized across 8 language files (en, th, zh, id, ms, ja, ko, ar) — each gains the 5-rule localized matrix entries.
  • RestaurantCardRoot.vue stretches 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 FomoSignal type added to both puma (RestaurantAttributes, SuggestionRestaurant) and tiger (RestaurantAttributes) with fields ruleId, coversToday, coversYesterday, coversLast7Days, restaurantViewsYesterday, maxSeatLeftNext7Days, lastBookingTimestamp.
  • OpenSearch index bumped: puma restaurants_v31restaurants_v33, tiger restaurants_v14restaurants_v15; new fomo_signal nested mapping (rule_id, covers_today, covers_yesterday, covers_last_7_days, restaurant_views_yesterday, max_seat_left_next_7_days, last_booking_timestamp) added.
  • SearchQueryBuilder projects fomo_signal in _source for both restaurant and suggestion searches.
  • mapRestaurantHit and search-suggestions map the ES _source.fomo_signal into the camelCase GraphQL fomoSignal payload (returns null when source missing).
  • New fomo-signal.test.ts covers 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: reduce and 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 fomoSignal payload 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

Design

API Blueprint

MethodPathURLDescriptionPayload
GET/api/v5/restaurants/:idhttps://{host}/api/v5/restaurants/:idStore 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 }
GETGraphQL searchRestaurants / searchSuggestionshttps://{host}/graphqlRestaurant + 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 }
KafkaRESTAURANTS_TOPIC UPDATE_EVENTinternalDelta { 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 reservations or any production MySQL table.
  • No engine / new schema.
  • New ClickHouse mart mart_restaurant_metrics is 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.cache keys live under fomo_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 NameDateWhat ChangedDescription
FOMO Signal v12026-08-05Backend waterfall + Kafka syncInitial rollout. 6-rule waterfall with hybrid Redis + CH caching. Real-time Dispatcher + 2h batch. Api::V5::RestaurantSerializer#fomo_signal surfaces the payload.
FOMO Signal v12026-08-05Hybrid search sync (felidae)ES index v31 → v33 (puma), v14 → v15 (tiger); FomoSignal GraphQL type + mapper; new test suite.
FOMO Signal v12026-08-07Hybrid 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.