FDA food recall notices are public, but they are not usable. A typical openFDA record tells you the product, the firm, a Class I/II/III label, and a free-text distribution_pattern like "FL, MI, MS, and OH." or "NY, NJ, CT, PA, and online sales nationwide". There is no map, no running count of high-risk events in a state, and no single place that answers “is this hitting where I live, and how bad is it?” I built RecallRadar because I was tired of reading those fields and still not knowing where the risk actually landed.
RecallRadar is a serverless AWS pipeline that polls the openFDA food enforcement API every six hours, parses geographic impact out of that free text, stores normalized records in DynamoDB, and serves an interactive US map plus a live feed from CloudFront.
![Animated dashboard walkthrough: a choropleth map of the US colored by recall volume, a Louisiana tooltip showing 78 total recalls broken into high, medium and low risk, and a scrollable feed of recent food recalls]
- Repo: github.com/Baricodes/RecallRadar
- Stack: Python 3.12 / ARM64 Lambda, DynamoDB, API Gateway, EventBridge Scheduler, S3, CloudFront, Terraform
-
Region:
us-east-1, personal AWS account, ~$3/month
Why: McBroken, but for food recalls
The inspiration was McBroken, which Rashiq Zahid launched in October 2020 to answer one dumb, universal question: is the McDonald's ice cream machine broken? He reverse-engineered McDonald's internal ordering API and pointed a bot at it, trying to add a McSundae to a cart at every US location every 30 minutes. If the item would not add, the machine was down, and that store became a red dot on a map of roughly 10,000 restaurants. A joke premise, executed seriously enough that The Verge and CNN both covered it.
I wanted something with that same shape: a little funny, a little ironic, and still genuinely useful. Broken soft serve is a punchline and contaminated food is not, but both are public data that nobody had bothered to put on a map.
The timing is a coincidence, and that is the genuinely ironic part. I built RecallRadar in June 2026, about two months before writing this. In the past week alone, the FDA has upgraded two food recalls to Class I, its highest risk level: roughly 1.6 million dozen eggs tied to a Salmonella outbreak that sickened at least 98 people across 17 states and hospitalized 26, and Publix's GreenWise frozen organic berries over E. coli, distributed across eight states. As of August 20, 2026, both investigations are still open. That second recall is precisely the question this project exists to answer — which eight states? — so I decided to publish the write-up now.
Architecture
Two paths share one table.
Write path. EventBridge Scheduler fires recallradar-ingestion every six hours (15-minute flexible window). The Lambda paginates openFDA (limit=100, 90-day report_date window), parses distribution_pattern, and batch-writes items keyed by FDA recall_number. Failed invocations retry twice, then land in an SQS DLQ (14-day retention). A parse failure does not abort the batch: it emits RecallRadar/ParseFailures and stores the record with empty affected_states.
Read path. The React SPA sits on a private S3 bucket behind CloudFront OAC. The browser calls /api/* on that same distribution. A CloudFront Function strips the /api prefix; CloudFront injects the API Gateway key as an origin header the browser never sees. API Gateway (REST, AWS_PROXY) invokes recallradar-query, which hits a GSI for classification-only lists and scans when the caller filters by state. GET /recalls/stats does a projected scan and aggregates the map.
Ingestion and query are separate functions, roles, and zip packages. The ingestion role can write the table and PutMetricData only in the RecallRadar namespace. The query role can Query / Scan / GetItem. Neither can invoke the other.
Decisions and tradeoffs
Lambda over ECS. Ingestion is four bursts a day; query traffic is a handful of dashboard loads. Fargate with a min of 1 would idle 23+ hours for a table that updates when the FDA does. ARM64 Python cold start is acceptable on a page that already waits on a stats scan. Idle cost beat p99.
On-demand DynamoDB, not provisioned. Writes arrive as a batch, then nothing happens for six hours. Provisioned capacity would be sized for the spike and paid through the idle. PITR and SSE are on because restoring the table is cheaper than re-deriving it from openFDA after a bad terraform destroy.
Six-hour polling, not 15-minute. openFDA food enforcement updates on the order of days. Fifteen-minute polling is 672 invocations a month to catch maybe one meaningful update; six hours is 28. Freshness within a business day was the bar. There is no FDA webhook, so polling is the design, not a compromise against a better event source.
Regex over Bedrock for distribution_pattern. The field is abbreviations, full state names, and a handful of nationwide phrases. A Python function does that at ingestion time with no extra IAM, no model latency, and no per-record bill. Bedrock is in the diagram as a later idea for reason_for_recall summaries — a higher-value job than deciding that "State of California" means CA.
CloudFront as the API facade. Early deploys baked the execute-api URL into the React build, which either leaked an API key into the browser or left the endpoint open. Now the bucket is private (OAC) and CloudFront attaches x-api-key as an origin header, so direct execute-api calls fail. The usage plan is 10 rps / 20 burst — enough for the dashboard, not enough to scrape the table through my account. REST over HTTP API bought keys and usage plans with less novelty, and Cognito is the wrong auth model for a public read-only map.
Scan for /recalls/stats, not a second table. Designed for < ~30K items; a 90-day food-enforcement window is a few thousand. A projected scan on dashboard load is simpler than Streams plus a materialized aggregate. It is also the first thing that dies under load. I shipped it on purpose rather than pretending a GSI can count every state in a list attribute.
Two Lambdas, ARM64, 256 MB. Terraform archive_file excludes keep each zip to one handler. Ingestion gets 120 s to paginate a remote API; query gets 30 s so the stats scan finishes inside the browser request.
Hard part 1: turning a sentence into a map
The interesting data is not in a column. distribution_pattern is a journalist’s sentence. Nationwide must expand to every contiguous state plus DC so the choropleth is honest. Mixed phrases ("NY, NJ, CT, PA, and online sales nationwide") are nationwide, not four states. Empty or unparseable text must not fail the write.
# lambda/shared/state_parsing.py
is_nationwide = any(signal in pattern_lower for signal in nationwide_signals)
affected_states = set()
if is_nationwide:
affected_states = set(CONTIGUOUS_STATES)
else:
for match in re.findall(r"\b([A-Z]{2})\b", pattern):
if match in US_STATES:
affected_states.add(match)
for name, abbrev in STATE_NAMES.items():
if name in pattern_lower:
affected_states.add(abbrev)
"OR" counts (Oregon); random two-letter tokens do not. "State of California" becomes CA. Tests cover "FL, MI, MS, and OH.", nationwide retail language, and mixed nationwide + abbreviations.
The catch is structural. You cannot put a list on a GSI partition key. Classification-only feeds use classification-date-index (ScanIndexForward = false). ?state=LA is a Scan with contains. That matches how FDA publishes the field, and it is why the map is cheap to render (stats already walked every item) and expensive to filter as the table grows.
The PK is the FDA recall number, so a re-run overwrites the 90-day window instead of diffing. Pagination stops at skip >= min(total_available, 26000) because openFDA will not let you walk forever.
Hard part 2: one hostname, a private bucket, and a key the browser never holds
The dashboard had to be one hostname. S3 + CloudFront for static files is easy. A second origin under /api/* is where it got sharp.
The API origin uses origin_path = /v1 so the stage lives in the distribution, not in React. The SPA calls /api/recalls; API Gateway expects /recalls:
// CloudFront Function, viewer-request on /api/*
function handler(event) {
var request = event.request;
if (request.uri === "/api") request.uri = "/";
else if (request.uri.indexOf("/api/") === 0)
request.uri = request.uri.substring(4);
return request;
}
That cache behavior uses default_ttl = 0 and forwards query strings — /recalls?state=LA is not index.html. Private OAC also means S3 403/404 must map to /index.html, or CloudFront surfaces AccessDenied instead of the SPA.
The key stays in Terraform state (sensitive) and the origin config. Lambda CORS can be * because the credential is not a browser header. That is not user auth; it is keeping a personal API from being an open DynamoDB proxy.
What broke / what I would change
I shipped the CloudFront dashboard before the first ingestion run. The site loaded, the API returned 200, and every state was gray because DynamoDB had zero items. The UI looked broken; the pipeline was fine. I now treat “invoke ingestion once after apply” as part of deploy, not as something the six-hour schedule will get to eventually.
I also overbuilt, twice, in a week. Phase 3 added CPSC, NHTSA, USDA, extra FDA adapters, Step Functions, and more GSIs. Adding CategoryDateIndex left the table UPDATING while Terraform waited on DynamoDB — a few hundred items, index still CREATING. I reverted ~1,400 lines rather than keep an access pattern I had not measured. Phase 4 added Streams, a second table, trend compute, and a briefing generator; that came out two days later (~2,800 lines). A unit test still imports the deleted shared.analytics_utils module. Incomplete reverts are a failure mode, not a footnote.
source-date-index and status-date-index are still on the table; the query handler only uses classification-date-index. On-demand means I pay when they are written, not when they sit. I would not add them today.
What I would change first, before any model or extra agency:
-
Materialize stats. Dashboard load should not scan. A scheduled compute plus 60–300 s of CloudFront cache on
GET /recalls/statsis the real scale path — I have already written and deleted a worse version. -
Invert
affected_states. StoreSTATE#LAitems (or a sparse GSI) at ingestion time.containson a list will not survive a second data source. - Do not add GSIs for a source I have not ingested. Index creates are an availability event for Terraform even on a tiny table.
-
Custom domain.
*.cloudfront.netplus a full invalidation on every deploy is not what I would run for anyone else.
This is a personal account, one region, 10 rps, and a stats endpoint that is honest about scanning.
Numbers
| Thing | Value |
|---|---|
| Calendar time, first commit to current dashboard | 8 days (2026-06-16 → 2026-06-24) |
| Terraform | ~1,570 lines, 7 modules |
| Lambda Python | ~690 lines (ingestion, query, shared parse/coords, tests) |
| Dashboard JS/CSS | ~1,960 lines |
| Ingestion schedule | 4 runs/day, 28/month, 15-minute flexible window, 2 retries, 1-hour max event age |
| Lambda size / timeout | 256 MB both; 120 s ingest, 30 s query; Python 3.12, arm64 |
| API throttle | 10 rps, burst 20 |
| Lookback / page size / skip cap | 90 days / 100 / 26,000 |
| DynamoDB | on-demand, PITR, SSE; PK recall_number, SK {source}#{report_date}
|
| CloudFront | PriceClass_100, OAC, /api/* TTL 0 |
| Log / DLQ retention | 14 days |
| Monthly cost (typical personal use) | ~$3, almost all CloudWatch dashboard + alarms |
Compute and data plane stay inside free-tier-shaped usage at a few thousand items and ~1,000 API calls a month. The $3 is CloudWatch. An ingestion job that fails silently is worse than a gray map.
Links and next steps
- Code: github.com/Baricodes/RecallRadar
- API notes: docs/API.md
- Data: openFDA food enforcement
The next useful work is the boring kind: precomputed stats, an inverted state index, and deleting the GSIs nothing queries. Two features are planned on top of that foundation, in this order.
Images of the recalled items. A notice that reads "GreenWise Organic Whole Blueberries, 10-ounce, UPC 41415-06753" is far easier to act on with a photo of the bag next to it. openFDA carries no images, so this is a second ingestion path against FDA and firm press releases, with assets in S3 behind the existing distribution — a scraping and storage problem, not an API call.
A recall chatbot. Natural-language questions ("was anything I bought at Publix last month recalled?") answered from the same table via Bedrock. This is deliberately sequenced after the stats work: retrieval sitting on top of a full-table scan is a bill, not a feature.
Beyond those two, expanding to more data sources — CPSC, NHTSA, USDA, the set I reverted in Phase 3 — is something I would consider for a future upgrade, but it is not set in stone. The same goes for Bedrock risk summaries. Both are candidates rather than commitments, and neither is worth revisiting until stats are precomputed and affected_states is inverted. I have the scars from trying both too early.














