Thin clients, fat pipeline: the infrastructure shape of AIIM
How a market-analytics thin client app that runs on iPhone, iPad, Mac and Android gets away with almost no backend logic in the app, and why every design decision traces back to one rule when creating its infrastructure pipelines.
AIIM is a market-intelligence app: a nightly technical scanner, municipal bond risk scores,
world P/E ratios, county-level economic nowcasts, ETF look-through, AI-built portfolios and a
chat assistant that can drive the screens. Behind it are 43 scheduled jobs, ten
long-running agents, four local databases, seven Cloudflare KV namespaces, a D1 database,
a PlanetScale Postgres instance and two Durable Object classes.
The app itself knows almost none of that or the infrastructure pipelines. That is the point of this article.
This is the first post in a ten-part series. It describes the overall shape of the system, the
boundaries between its parts, and what the shape buys and costs us. The later posts zoom into one zone each. Everything here was traced from code in October 2026 for a set of architecture diagrams, and the diagrams below are simplified versions of those.
AIIM thin client infrastructure pipelines: The one rule
The apps are thin clients
Anything stored in Cloudflare is read through the worker. The app never calls an upstream data source for it.
Nearly every structural choice follows from this rule, so it helps to state it before drawing
anything. The phone doesn't hold an FMP API key, doesn't know which Postgres view a number came from, and doesn't do joins. It sends an HTTPS GET for a path like
`/api/analytics/scanner-results` and renders the JSON it gets back.
The rule is short, but it took enforcement to make it true. For a while the app had a
`YahooFinanceService` that fetched price history and company names from Yahoo directly. It
broke the rule, and it was also quietly dead: Yahoo's `quoteSummary` endpoint had started
returning 401 for every symbol, including AAPL, so the valuation panels that relied on it were
blank everywhere. We deleted the service and moved the same data behind the Worker, which now serves price history from its own stores and falls back to FMP itself. The client got simpler and the failure moved to a place we could see and fix without an app release.
A useful property of a one-sentence rule is that it gives code review a test. "Does this new
screen call anything other than `analyticsController`?" has a yes-or-no answer. In our
mobile codebase that file is the data I/O layer: every method on it is a namespaced `get*`
call that ends at a Worker path. If a fetch isn't in there, it's either an exception we have
drawn on purpose or a bug.
AIIM Infrastructure pipelines: Four zones
Here is the whole system on one page:

① Upstream
Financial Modeling Prep (FMP) supplies most of the data: prices, statements, estimates, ETF holdings, news and ratings. SEC EDGAR provides filings and fund holdings (N-PORT), Interactive Brokers provides fundamentals and real-time bars, and the macro side comes from FRED, GeoFRED and the BEA. Municipal bond data comes from EMMA and the rating agencies.
② Local platform
This is a Mac plus a few LAN servers. Prefect runs the scheduled work. A small process supervisor (the governor) keeps a set of agents alive, and the agents coordinate through Redis queues. The heavy compute happens here: the market scan, ML models,
bond risk scoring and outcome labelling. The results live in PostgreSQL, Redis, Neo4j and
Couchbase.
③ Cloudflare
One Worker sits in front of every store. KV holds whole-payload documents, D1 holds the relational data, Durable Objects hold per-user state, and Hyperdrive connects the Worker to a PlanetScale Postgres database for tables too big for D1.
④ Clients
A React Native app (one codebase for phone, tablet, Mac and Android) and a Vite/React PWA on Cloudflare Pages. That's the shape you can hold in your head. The next diagram is the one we actually debug from.
The full system context
The one-page view hides three things that matter in practice: the services the Worker calls on its own behalf, the identity provider, and the stores that live on the device. Here is the
same system with those added:

The rest of this section walks the zones left to right, with the counts as they stood when we
traced them.
Upstream: five providers, one dominant
FMP is the workhorse. It feeds five of our seven ingest domains (prices, fundamentals, fund
holdings, news, and the intraday fallback feed) through two different client libraries. That
concentration is a known risk, and it shaped one Worker decision: when the edge needs a
live number that nothing has published yet, it asks FMP too, using its own key. We never had to give that key to a phone.
SEC EDGAR is the other large source: bulk archives, the Atom RSS feed for new filings, and
N-PORT fund holdings. Interactive Brokers' TWS gateway supplies fundamentals and real-time bars for the intraday stream. FRED and GeoFRED supply national and county macro series, the BEA supplies regional GDP, and EMMA plus published rating actions supply municipal bond data.
Everything upstream is pulled, on a schedule, by code we run. No provider pushes to us, and none of them can see Cloudflare.
Local: where the work and the state live
The local platform is deliberately the fat part. It has three layers.
Scheduling: A single `orchestration/deployments` process runs Prefect's `serve` for
all 43 deployments: pricing updates at 10:00 and 16:30, the market scan at 21:00, G20 macro forecasts at 21:00, the hourly economics sync between 08:00 and 18:00 on weekdays, a four-hourly muni risk publish, a weekly fundamentals load on Saturday morning, and so on.
Agents: `util/agent_governor` supervises ten long-running processes: technical
analysis, the intraday stream, ingestion, aggregation (bond risk, world P/E, graph
pre-aggregation), a Postgres-view-to-Redis agent, a query agent over Neo4j and Couchbase, the bond risk agent, the SEC RSS reader, a Celery worker and cache maintenance. They coordinate through Redis inbox and outbox lists and publish heartbeats there; the governor restarts anything whose heartbeat goes stale.
Stores: PostgreSQL holds the system of record across four schemas. Redis holds hot
results such as `scanner:results:daily` and the agent queues. Neo4j holds the fund-and-holding graph. Couchbase holds SEC RSS documents.
The last local layer is the publish scripts, mostly `scripts/sync_*`. They read a local
store, shape JSON, and POST it to the Worker. They are the only code on the LAN that knows Cloudflare exists.
Cloudflare: one Worker and its stores
There is one Worker, `workers/edge-router`. It binds seven KV namespaces, one D1 database, a Hyperdrive connection to PlanetScale, two Durable Object classes and a rate limiter. Here the important part is what each store is for.
Store | Holds | Shape | Example |
|---|---|---|---|
KV (7 namespaces) | Finished, whole payloads | One JSON document per key | `scanner-results`, `world-map`, `price-history/{SYMBOL}` |
D1 | Relational data the Worker queries | ~20 tables | price_history`, `scanner_history`, `company_profile`, `pe_ratio_history` |
PlanetScale via Hyperdrive | Tables too big for D1 | Postgres | fundamentals, fund constituents |
Durable Objects | Per-user and per-day state | SQLite per object | portfolios, assistant quota |
At the last catalog dump, KV held about 1,660 keys. Most of them, 1,170, were per-symbol price histories in one namespace; the rest were a few hundred aggregate payloads spread over the others. One namespace was empty because its producer (the Neo4j network sync) is paused, and another was written to but never served.
Services the Worker calls
Four third parties are reached from the Worker on request, never from the LAN and never from the app:
Anthropic: The chat assistant runs inside the Worker. Each turn is up to six model
iterations, with tool calls dispatched in-process to the Worker's own routes.
SnapTrade: Brokerage import. The Worker signs requests itself and stores each user's
SnapTrade secret in that user's Durable Object. The app only ever opens SnapTrade's
connection portal in the system browser, from a URL the Worker hands back.
Auth0: The identity provider. The app talks to it for login (PKCE); the Worker talks to
it only to fetch the signing keys (JWKS) it verifies tokens against, and caches them.
FMP live quotes: A last-resort fallback when a price history isn't in KV or D1, a public
quote route, and fundamentals that have gone stale. This is the only upstream source the
edge touches, and it does so with a Worker secret.
Clients and the device
The React Native app is one codebase for iPhone, iPad, Mac (as a Designed-for-iPad app) and Android. The PWA is a separate Vite/React build served by Cloudflare Pages at
`app.aiimfirst`. Both call the same Worker. Thin doesn't mean stateless. The device holds four stores:
Store | What it holds |
|---|---|
AsyncStorage | Response cache (`cache:{path}`, 5 minutes by default, up to 12 hours for slow-moving data), the offline copy of cloud portfolios, cached closes for valuation |
Keychain / SecureStore | The Auth0 access token; on Apple devices, a chunked copy of signed-out portfolios |
iCloud key-value store | Signed-out portfolios on iOS, iPadOS and Mac (the store caps at about 900 KB |
Android Auto Backup file | signed-out portfolios on Android, with a `.prev` copy |
Some computation lives here too. Pattern detection and technical indicators run on the device from price history the Worker served, and portfolio valuation (a time-weighted return) runs entirely on the device from closes it fetches incrementally. That's still consistent with
the rule: the device computes on data it got from the Worker, it doesn't go and get data
itself.
Data moves in one direction per boundary
The table below matters more than either diagram:
Boundary | What crosses it | Auth | Direction | If it breaks |
|---|---|---|---|---|
Upstream → local | Raw prices, statements, filings, macro series | API keys, session | Pull, scheduled | Local stores go stale; nothing downstream notices until a publish carries old data |
Local → Cloudflare | Finished JSON payloads and row batches | Shared bearer token | Push only | Edge keeps serving the last good payload until its TTL expires |
Worker ↔ PlanetScale | Fundamentals, fund constituents | Binding | SQL over Hyperdrive | Fundamentals routes fail; KV-backed screens unaffected |
Worker → third parties | Live quotes, LLM calls, brokerage | Worker secrets | On request | That one feature degrades |
App → Worker | Everything the screens show | Auth0 JWT, or anonymous | Pull over HTTPS | The app shows cached data, or empty states |
App → Auth0 | Login and token exchange | PKCE | On demand | Signed-in features unavailable; anonymous reads still work |
App → LAN | Intraday bars and signals | Optional stream token | Pull over HTTPS | Intraday screen is empty; nothing else changes |
Two of these rows explain most of the design.
Local → Cloudflare is push-only: Cloudflare never reaches into the LAN, so there's no
tunnel, no inbound port and no VPN to maintain. Every publish is a POST from a local script to an internal Worker route, and the Worker does the storage write. If the home lab goes dark, the edge keeps serving the last good payloads until their TTLs run out.
App → Worker is pull-only, and it's the only path for stored data: Because the app only
ever talks to the Worker, the Worker is the single place to reshape payloads, add fallbacks,
enforce auth and change storage without shipping an app update. When we moved fundamentals out of D1 and into PlanetScale, no client changed.
Why push-only
We considered the obvious alternative: let the Worker call back into the LAN for anything it
doesn't have, through a tunnel. It would have made the edge "always fresh". We rejected it for four reasons.
1. Exposure: A tunnel gives the internet a path, however guarded, into a network that holds Postgres, Redis and the brokerage session. Push-only means the only listening service on the whole system is the Worker, which Cloudflare runs.
2. Availability coupling: With pull-through, a power cut at home becomes a user-visible
outage within seconds. With push, it becomes "data as of last night".
3. Cost shape: A request-driven pull would make edge traffic drive load on home
hardware. Push means local load is set by the schedule, not by how many phones open the app.
4. Shape control: A publish script decides exactly what leaves the LAN, in what shape. It
can drop columns, round numbers and skip an unchanged payload by fingerprint. Nothing leaves by accident because a route happened to expose a table.
The cost of push-only is that the edge only knows what Prefect scheduled jobs publish. A new screen needs a producer, a schedule, a key and a TTL, not just a query. Publishing has its own article because that seam is where most of our incidents have happened.
What happens during an outage
Because each boundary is one-directional, failures stay contained and show up as staleness
rather than errors. That's only true if TTLs are chosen with the schedule in mind, and we have
learned that the hard way.
The generic publish route, `/api/internal/sync`, writes a KV key with a 7-day TTL unless the
producer asks for something else. The comment in the Worker says why: it "covers a full week of job failures". A producer can pass `ttl: 0` for keys that should never expire, and several do: the valuation payload, news sentiment, the full municipal bond risk set and the model performance summary.
Neither choice is free. A key that never expires never goes blank, but it can also be wrong
forever without anyone noticing. A key with a short TTL goes blank as soon as its producer
misses a few runs, which is loud but at least honest.
The incident that taught us to check TTL against cron was on the Economics screen. Its
producer runs hourly from 08:00 to 18:00, Monday to Friday. The key was written with a 24-hour TTL. The last write on Friday evening expired on Saturday evening, and nothing wrote it again until Monday morning. The screen was blank for most of every weekend. The rule we took away: any TTL on a weekday-only schedule has to outlast the longest gap, which here is about 62 hours.

(The dates are illustrative; the pattern repeated every weekend until the TTL changed.)
Data in D1 behaves differently: rows don't expire. When we paused the Neo4j network sync to rebuild the graph, its KV namespace emptied as keys aged out, but the D1 `network_*` tables kept the last good sync, and the D1-backed graph routes kept working. That asymmetry is useful. KV is a cache of the last publish; D1 is a record of it.
Further down, the client adds one more layer. `client` caches successful, non-empty
responses in AsyncStorage. It doesn't retry; a failure throws and the screen falls back to the
cached copy. So during a Worker or network failure, a screen the user has opened recently still renders. A screen they have never opened shows its empty state.
The failure this design does not protect against is silent upstream death. If the Prefect
process dies, nothing publishes, the edge keeps serving, every screen looks fine for a week,
and then everything ages out together. [Article 2] is about making sure that doesn't happen quietly.
What it costs to run
The Cloudflare account is on Workers Paid. Its D1 allowances, as published when we checked in August 2026, set the main constraint on storage design:
D1 resource | Included per month | Beyond that |
|---|---|---|
Rows written | 50 million | $1.00 per million |
Rows read | 25 billion | $0.001 per million |
Storage | 5 GB | $0.75 per GB-month, hard limit 10 GB per database |
Reads are roughly a thousand times cheaper than writes. That one ratio decides where data goes:
Wide, read-mostly, whole-payload data goes to KV. The scanner results, world map, world P/E ratios and news sentiment are each one document, rewritten in full by a publish and read many times. KV costs effectively nothing per read and doesn't care how wide the document is.
Relational, queried data goes to D1, as deltas. Model performance history, scanner
history (seven nights), company profiles and P/E history are upserted with
`INSERT … ON CONFLICT`, in batches. Producers diff against what's there where they can: the company profile sync first fetches a manifest from the Worker and only sends changed rows.
Tables too big for D1 go to PlanetScale. Fundamentals and fund constituents live in
Postgres behind Hyperdrive, which also keeps those loads off the D1 write budget entirely.
We now estimate rows × runs per month against the 50-million write allowance before adding any D1-backed sync. Truncate-and-reload is off the table, because a nightly reload of a 100,000-row table alone would use 3 million writes a month for data that mostly didn't change.
One place where the budget is still easy to burn is the price history mirror. When a producer
writes `price-history/{SYMBOL}` to KV, the Worker also upserts every bar in that payload into
D1's `price_history` table and prunes rows older than 730 days. The upsert covers every bar
the producer sends, not only the new ones, so what counts toward the budget is the length of the payloads, not the number of new trading days.
Other running costs are small and bounded by design. The assistant measured about $0.016 per question during testing, with a 60-question daily limit per user and a $25 global daily cap
enforced by a Durable Object. Hyperdrive caches identical reads for 120 seconds and holds at most 10 origin connections to PlanetScale.
One number, end to end
The clearest way to see the architecture is to follow a single value. Take the closing price
of one stock as it appears on the StockDetail chart.

1. Ingest. The market-data sync flow runs at 10:00 and 17:00 ET. Its price script asks FMP
for end-of-day history for every symbol the scanner tracks.
2. Publish. For each symbol the script POSTs `{key, value, binding, ttl}` to
`/api/internal/sync`, with the binding set to `DEV_PRICES`. The value includes the company
name and an array of bars.
3. Store, twice. The Worker writes the KV key, then mirrors the bars into D1
`price_history` and the name into `symbol_names`, as separate statements so a failed name
write can't cost the bars.
4. Read. StockDetail asks for `/api/analytics/price-history/{SYMBOL}?limit=…`. This is
the one analytics path pinned to a single KV namespace, because charts fire many requests
in parallel and a multi-namespace scan per request was too slow.
5. Fall back. On a KV miss the Worker reads D1, honoring the client's `limit` (capped at
1,000 bars). On a D1 miss it fetches from FMP directly, using the same bar count.
6. Render. `client.` caches the response; the chart is an amCharts WebView with the data
injected, never fetched inside the WebView. Indicators and pattern detection run on the
device.
Every step is a place something can go wrong, and walking the chain from the right-hand end is how we debug. Two real examples from this exact path: a producer once wrote price-history keys into the wrong namespace (102 of them), where the pinned read path never looks, so they were paid for and never served. And both fallback tiers used to ignore the client's `limit` and return 100 or 30 bars, too few for a 200-day moving average, so the indicator silently disappeared on any symbol that wasn't in KV.
What the design costs you
Thin clients and a fat pipeline are a good trade for us, but it is a trade:
The Worker accumulates knowledge: Every reshape, fallback and alias the client is spared
lives in the Worker instead. It is about 4,700 lines in one file, and some of that is logic
a reader wouldn't expect at an edge router, such as turning yearly economics records into the indicator cards a screen expects.
Envelopes drift: With no shared schema between producer, Worker and client, routes have
ended up with different envelopes. Some are flat (`{ok, sectors}`), some double-wrapped
(`{ok, data: {ok, result}}`). The client's defensive accessor is `resp?.data ?? resp`, which
works but is a smell.
Freshness is bounded by the schedule: Nothing on the edge is newer than the last publish.
For a nightly scanner that's fine; for anything intraday it isn't, which is why intraday has
its own path.
Two copies of everything: The local store is the system of record, the edge holds a
published copy, and the device holds a cached copy of that. When numbers disagree you have three places to look, and several producers can write the same KV key. The scanner payload has three, and the last writer wins.
Some loads can only go through the Worker: Nobody holds the PlanetScale password;
Hyperdrive does. So every DDL statement and bulk load is an authenticated POST to the
Worker, not a `psql` session. It keeps credentials off laptops, and it makes ad hoc fixes
slower.
Silent failures look healthy: A green screen proves the edge has a payload, not a
recent one. That's why the series ends with per-panel "as of" stamps on the queue.
What deliberately never reaches the edge
Some data is kept off Cloudflare on purpose, and the reasons are worth spelling out:
The intraday stream: stays on the LAN for licensing reasons. A local agent writes Redis
and serves a small HTTP API itself. On the home network the app calls it directly; away from
home, the intraday screen is simply empty. The Worker even carries a comment where an
intraday branch would go, saying it is intentionally absent.
Chat history: stays on the device. For a chat turn the Worker writes only the quota
counters.
Signed-out portfolios: live in iCloud key-value storage plus the Keychain on Apple
devices, and in a backed-up JSON file on Android. On sign-in they are uploaded to the user's Durable Object and the local copy is removed.
Neo4j and Couchbase: are local-only. The fund-holdings graph is published as derived
payloads; the filing documents aren't published at all.
The intraday exception is the only place the thin-client rule bends, and it's drawn as a
dotted line on every diagram so nobody mistakes it for the main path. A few other paths bypass the Worker without bending the rule, because they aren't data the system stores: the Auth0 login exchange, the SnapTrade portal, and the chart libraries the WebViews load.
Why this shape works for a small team
The split puts the expensive, stateful, failure-prone work (scraping, model training,
graph builds) on hardware we control, where mistakes cost nothing beyond electricity. The
edge stays cheap and dumb: KV reads are effectively free, the Worker is mostly routing, and
outages upstream degrade gracefully into "data as of last night" rather than a crash.
It also means most changes ship without an app release. Adding a KV-backed endpoint can be as small as publishing a new key, because the Worker's generic analytics handler serves any key that matches the request path. Changing where a dataset lives, or how it's shaped, is a Worker deploy. Only a new screen needs the app store.
It also creates three real problems. The rest of this series covers them:
1. The local pipeline is a distributed system with no ops team. When a scheduler dies at
3 a.m., nothing pages anyone. [Article 2]
2. Publishing is the fragile seam. Several producers can write the same key, TTLs interact
with cron gaps, and D1 writes cost money. [Article 6]
3. A thin client still has to cope with a slow, partial or stale backend. That puts more
logic on the device than the word "thin" suggests. [Article 9]
The series
1. Thin clients, fat pipeline: the shape of AIIM (this article)
2. 43 cron jobs, one process: keeping a home-lab pipeline alive
3. Seven sources, seven domains: designing ingest
4. 21:00 ET: anatomy of a nightly market scan
5. Our scanner scored 100%. It was measuring nothing.
6. Push, never pull: publishing from a LAN to the edge
7. One Worker in front of everything
8. An LLM assistant inside a Cloudflare Worker
9. Thin client, smart device
10. Draw the system and it will tell you what's broken
Takeaways
Write the core architectural rule down in one sentence. If you can't, the diagrams will
sprawl, and code review has nothing to test against.
Make each boundary one-directional where you can. Push-only publishing from a private
network removes a whole class of exposure and turns outages into staleness.
Choose TTLs against the schedule, not in isolation. A TTL shorter than the longest gap
between runs is a scheduled outage.
Let the price of each store decide what goes in it. When writes cost a thousand times more than reads, publish whole documents to KV and send deltas to the relational store.
Follow one number end to end. Every hop is a place it can go missing, and the trace tells you where to look first.
Draw the exceptions to the rule, and draw them differently. A dotted line is cheap
documentation.



