Skip to content
Fig. 00 — Foodie · Sheet IndexWeb · Live

Selected work · nutrition ai at the edge

Foodie · nutrition intelligence at Cloudflare's edge

A recommendation engine that pairs travelers with the right dishes at any restaurant in the world. Three LLM providers behind one interface, session state in Durable Objects, and a menu ingestion pipeline that turns tens of thousands of dishes into scored, taste-aware suggestions.

Exhibits · Recommendation gallery · Production outputCaptured Jul 2026 · flykitt.com/foodie
Case sheetFile 03 / 04 · Flykitt
Role

Senior Full-Stack Engineer / Backend Architect

Timeline

2023 to 2025

Contribution split · I implemented
65%
backend + ai
35%
product

foodie · orchestration, sessions, ingestion workflows

StackShow 8 items
Cloudflare Workers · Durable Objects · Workflows + Queues · Hyperdrive → Postgres + PostGIS · Vectorize + Workers AI · AI Gateway → OpenAI · Anthropic · Cohere · R2 + Cloudflare Images · React Router 7

Context

Flykitt's protocols tell travelers when and what to eat. A protocol that says "low inflammation, high protein" doesn't survive contact with an unfamiliar menu in another language, though. Foodie closes that gap. Point it at a restaurant anywhere in the world and it scores the menu, shortlists the dishes that match your goals, and proposes modifications when a good dish is one substitution away from being a great one.

Problem

Menus are unstructured, inconsistent, and endless. The moment of need is standing in front of one, which makes latency part of the product. The engine had to ingest hundreds of restaurants offline, then serve conversational, taste-aware suggestions online, while keeping per-request LLM cost from scaling with menu size.

The decision constraintlive at the table
241

One menu, hundreds of dishes

A traveler opens a menu with a taste profile in their pocket and about a minute of patience. The engine on the right has already ingested the restaurant, scored every dish, and reduced the choice to a shortlist, before the waiter comes back.

Foodie menu scorecard showing three dishes with score rings (Loaded Nachos 42, Chicken Teriyaki Bowl 68, Grilled Salmon 90) and macro breakdowns
Menu scorecard · dish-level output · production ui

What every unfamiliar menu asks the traveler to decide

Ingredients
seed oils · added sugar · hidden gluten
Sourcing
farmed vs wild · grass-fed · processed
Macros
protein · carb · fat mix per dish
Language
translate · adapt to local cuisine

Architecture

Every part of Foodie runs on Cloudflare. A Worker fronts the product. Session state lives in a Durable Object per user. Concurrent lookups for the same place converge on a second Durable Object per (geohash, profile) key, so two travelers opening the same menu at the same moment do the recall, the rerank, and the LLM pass exactly once. Spatial recall is real PostGIS behind Hyperdrive, on a GiST-indexed geography column. Vectorize prefilters against the taste profile. LLM traffic egresses through AI Gateway to OpenAI, Anthropic, and Cohere, each routed to the step it is best at. The request path never leaves Cloudflare, and there is no shared connection pool to warm up.

Fig. 01foodie · the platform · edge, sessions, dedup, data, ingestion, providers
CLIENTEDGESESSIONS & DEDUPDATAASYNC INGESTIONAI GATEWAYdineredge workerrouter · react router 7Cache API30s scoreboardsSession DOtaste profile · convoSearch DO(geohash, profile) · coalescePostgres + PostGIShyperdrive · gist(loc)Vectorizedish embeddingsKVhot tiles · 24hR2menu blobsImagescf · variantsQueuesrestaurant fan-outWorkflowsdurable steps · retriesWorkers AIbge-large · visionAI Gatewaycaching · retries · failoverOpenAIAnthropicCohereprobe · 30sprofilest_dwithinrerankscorewritebackembed · ocrcommitpostgis st_dwithin over gist(loc geography) · vectorize prefilters · llms via ai gateway

one worker at the front, two kinds of durable object behind it, external llms only via ai gateway.

Restaurant data enters the system three ways: on-demand when a search hits an unknown tile, on a scheduled refresh for stale tiles, and directly when a restaurant signs up. Every path lands in Queues, fans out per restaurant, and runs one Cloudflare Workflow per restaurant with durable steps. Fetch, OCR (Workers AI vision on scanned menus), extract to canonical dish records (LLM via AI Gateway), normalize and translate, geocode into a PostGIS geography column, embed each dish with bge-large on Workers AI, and commit atomically to Postgres, Vectorize, R2, and Cloudflare Images. A failed step resumes on retry; the whole restaurant never restarts.

Fig. 02restaurant ingestion · sources · queues · durable workflow steps · data outputs
SOURCESWORKFLOW · DURABLE STEPSDATA OUTPUTSon-demandnew tile searchedcron refreshstale tile · 14dself-signupmenu url · pdfQueuesfan-out · idempotentfetchocrextractnormalizegeocodeembedcommitWorkers AIbge-large · visionAI Gatewayllm per stepPostgres + PostGISgist(loc)Vectorizedish embeddingsR2originalsImagescf · variantscommiteach step durable · a failing step resumes without restarting the restaurant

The read path is a hierarchy that answers without doing work whenever it can. Cache API first, 30 seconds. KV next, 24-hour scoreboards keyed by geohash plus profile. Then the Search Durable Object, one instance per (geohash6, profileHash) key. If two travelers hit the same key at the same time, the second attaches to the first request's in-flight promise instead of rerunning the pipeline. On a miss, the DO runs PostGIS ST_DWithin over the GiST-indexed geography column, filters candidates through Vectorize against the taste profile, and batches LLM scoring through AI Gateway. Results write back to the DO, then KV, then Cache API, so a third caller within the TTL is a straight cache hit.

Fig. 03recommendation · two diners, one search do, one pipeline
CACHE TIERONE DURABLE OBJECT PER (GEOHASH, PROFILE)SPATIAL RECALL · RERANK · LLMSdiner Afirst arrivaldiner Bfolloweredge workercomputes keyedge workercomputes same keyCache API30sKV24h · geohash tilesSearch DOin-flight promise · coalesces followersPostgres + PostGISst_dwithin · gist(loc)Vectorizetaste rerank · top 100AI Gatewaybatched scoringOpenAIextract · convoAnthropicmacro reasoningCoherererankprobe · missst_dwithinrerankscorewritebackkey = geohash6(lat, lng) :: profileHash(taste)diner B attaches to diner A's in-flight promise · no postgres, no vectorize, no llm tokens

Menu photos and dish imagery flow through R2 for originals and Cloudflare Images for delivery. Signed variants for the printable scoreboard PDF, thumbnail and hero variants for the app, one origin regardless of whether the image came from a scrape, an upload, or a Workers AI generation.

§04

The product in production

Recommendation gallery · Product screens · Real output

What the engine actually produces, shown as-shipped: four recommendation cards spanning four cuisines, and the two product screens travelers meet first. The UI is the work of the wider Flykitt team; the pipeline that fills these cards is the scope of this case study.

Fig. 01 — Recommendation output · four cuisinesSame engine

Cross-cuisine recall

One scoring pipeline, four unfamiliar menus.

Same taste profile, four cities. Each card is composed from the same ingest, analyze, and score pipeline. Dish shortlists, macro ratings, and score rings all fall out of the shared engine, not hand-authored per restaurant.

Mexican
El Agave Taqueria, best choice, score 88, three carne asada / al pastor / veggie fajita recommendations
El Agave Taqueria
Best choice · 0.5 km · Score 88
Thai
Siam Orchid Thai, score 82, three panang / drunken noodles / pad thai recommendations
Siam Orchid Thai
Open now · 1.8 km · Score 82
Vegan
Kindred Spirits Vegan Cafe, score 80, three plant-based recommendations
Kindred Spirits Vegan Cafe
Open now · 1 km · Score 80
Indian
Spice Route Curry House, score 85, three Indian recommendations
Spice Route Curry House
Closed · 3 km · Score 85
Real production cards · captured from flykitt.com/foodieScored per taste profile · not curated by hand
Fig. 02 — Product journey · SCAN → UPLOADLive · web app

Two doors, one pipeline

How the traveler hands a menu to the engine.

Step 1 · Scan
Foodie landing screen with Foodie Menu Scanner hero, Foodie Restaurant Search shortcut, and an empty Menu Scans state with an Analyze Your First Menu prompt
Analysis home

The traveler opens Foodie to one of two doors: scan the menu in front of them, or search healthy restaurants nearby. Past scans keep working sessions warm.

Step 2 · Upload
Foodie menu analysis input screen with a place-name field, photography tips, and a large drop zone for menu photo uploads
Menu analysis kickoff

Handing the engine a menu: a place name plus one or more photos. The upload kicks the Workflows pipeline; the session Durable Object holds the traveler's taste profile through the wait.

Captured from the live web app · mobile viewportCallouts trace the engine surface behind each control

Key decisions

  1. Session state in Durable Objects, not in a database.

    A recommendation session is a conversation: taste profile accumulates, suggestions get rejected, context builds. A Durable Object per session keeps that state at the edge next to the compute, with strong consistency and no connection-pooling to a shared store.

    Impact·Personalized sessions survive network hops, restarts, and out-of-order events, with zero session-store operations to run.

  2. More than one LLM provider, behind one interface.

    Dish extraction, conversational suggestion, and rerank have different quality, latency, and cost profiles. Routing across OpenAI, Anthropic, and Cohere per task, rather than standardizing on one vendor, lets each step use the model that is actually good at it.

    Impact·A provider outage degrades one capability instead of the whole product. Per-task cost becomes a routing decision rather than a rewrite.

  3. Long-running analysis belongs to Workflows, not requests.

    Menu ingestion (fetch, parse, extract, score, index) is batch work with retries, not request work. Cloudflare Workflows carries the pipeline with durable progress, so a failed step resumes instead of restarting the whole restaurant.

    Impact·Tens of thousands of menu items processed reliably as a background concern. The traveler feels progress, not failure.

  4. One Search Durable Object per (geohash, taste-profile) key.

    Two travelers in the same neighborhood with the same dietary profile don't need two of everything. Naming the DO with hash(geohash6, profileHash) puts them on the same instance; the DO holds an in-flight promise so the follower attaches instead of restarting the pipeline.

    Impact·Duplicate concurrent lookups collapse to one compute. Followers within the TTL are Cache API hits, so no Postgres, no Vectorize, no LLM tokens.

  5. Postgres + PostGIS behind Hyperdrive, not a homegrown geohash index.

    Nearby-restaurant recall wants real spatial SQL (ST_DWithin over a GiST-indexed geography column), not an approximate tile lookup. Hyperdrive keeps the Cloudflare-only story honest: the Worker binds to Postgres like any other resource, no cold connections, no AWS credentials on the request path.

    Impact·Recall is a range scan, not a table scan. Adding filters (cuisine, price band, hours) stays inside the same query plan without a schema rewrite.

Outcomes

Menu itemsM
50K+

menu items analyzed into scored, personalized suggestions

Decision timeM
~60%

reduction in customer decision time in front of a menu

ProvidersI
3

LLM providers orchestrated behind one session model

The pipeline runs in production against real travelers on a paid tier. Beyond the numbers, the choice to isolate the AI workloads at Cloudflare's edge (rather than in Flykitt's AWS core) kept the rest of the platform's failure domain intact through the entire Foodie build.

Provenance of numbers:MmeasuredIimplemented