
How a small trading-practice web app got to a P99 under 200 ms in every region without a "real" backend — a technical walkthrough of the parts you can copy. The problem: chart apps are secretly data apps ChartMini is a browser-based tool where traders practice reading candlestick charts without knowing the ticker, timeframe, or date. From the outside, it looks like a small chart widget. Under the hood, it's a data-delivery problem dressed up as a UI. A single practice session pulls a lot of price data across multiple timeframes, and users hop between assets fast enough that any per-request cold start above ~300 ms is noticeable. Multiply that by ten locales, an SEO surface of a few hundred articles, and a bill you actually want to look at, and you end up with the same architecture problem a lot of small B2C apps have: How do you serve a data-heavy, interactive app globally, without paying for a server that's idle 80% of the day? This post is the answer we landed on — SvelteKit compiled to a Bun runtime, deployed to a handful of Fly.io regions, with the actual price data living in read-only SQLite files replicated to every region , and a single Postgres for the tiny bit of state we actually need. We're not going to talk about the pieces that make ChartMini itself interesting — how sessions are constructed, how the "blind" part stays blind, how answers are scored. Those are the products. Everything else in the stack is fair game, and most of it is copy-pasteable. Architecture in one picture ┌───────────────────────┐ ┌────────────────────────────┐ │ Browser (Svelte) │◀──────▶│ Fly.io app, N regions │ │ Canvas renderer │ HTML │ SvelteKit on Bun │ │ Small state store │ │ HTTP/3, Brotli │ └───────────┬───────────┘ └──────┬──────────┬──────────┘ │ │ │ │ Range requests │ │ Postgres (auth, saved runs) │ over persistent conn │ │ single region + read replicas ▼ ▼ ▼ ┌────────────────────────┐ │ Read-only SQLite │ │ price shards, one │ │ per Fly region volume │ └────────────────────────┘ Three moving parts, in decreasing order of traffic: Price data in read-only SQLite shards — the vast majority of bytes served. One shard per (asset class, timeframe), sitting on a Fly volume in every region. Queried by byte range over HTTP for cold reads, mmap'd for warm reads. The SvelteKit app itself — server routes and SSR, running on Bun in every region we care about. First byte is served locally. A tiny Postgres — auth, a handful of user-scoped tables, and job state. Touched on maybe 5% of requests, sits in one region with read replicas. The trick isn't any single piece. It's the ratio . If you can push most of your traffic onto tier 1 (in-region, memory-mapped), the "expensive" tiers stop mattering. Why SvelteKit + Bun, and not the framework you expected We started on a more mainstream stack. Two things pushed us off it: 1. Runtime footprint. SvelteKit's server bundle for our full app is under 3 MB. That's not just a bragging-rights number — it means every region we deploy to boots the whole app into memory in under a second, and we can afford to run more regions instead of fewer bigger ones. 2. Bun's HTTP layer is genuinely fast. For the request shape we have — small JSON responses, many concurrent connections, Range headers on binary reads — Bun's server handles noticeably more RPS per core than the Node equivalent. We aren't CPU-bound; we just get more headroom for free. The Svelte side is boring in a good way. Components are components. State is a couple of stores. The chart is a client-only component that renders directly to a <canvas> — we wrote a small purpose-built renderer instead of pulling in a general-purpose charting library, because 90% of what those libraries do we don't need, and the 10% we do need (crosshair, zoom, occlusion) is a few hundred lines. <!-- Simplified — real code is longer --> <script lang="ts"> import { onMount } from 'svelte'; import { drawCandles } from '$lib/chart/render'; export let bars: Bar[]; let canvas: HTMLCanvasElement; onMount(() => { const ctx = canvas.getContext('2d')!; const ro = new ResizeObserver(() => drawCandles(ctx, bars)); ro.observe(canvas); drawCandles(ctx, bars); return () => ro.disconnect(); }); </script> <canvas bind:this={canvas} /> Nothing about that is clever. But every line of it is code we control, which matters for the next section. SQLite as a delivery format (yes, really) This is the piece most people push back on, so it's worth being specific. For each (asset class, timeframe) pair we generate a read-only SQLite file — indexed on (symbol, ts) , with a handful of pragmas set for read-heavy access: PRAGMA journal_mode = OFF; -- read-only, no journal needed PRAGMA synchronous = OFF; PRAGMA locking_mode = EXCLUSIVE; PRAGMA mmap_size = 268435456; -- 256 MB PRAGMA cache_size = -65536; -- 64 MB page cache That file gets shipped to every Fly volume via Litestream replication. The generator writes to a single "source" SQLite in a build region; Litestream streams the WAL to S3; each Fly region has a small sidecar that restores from S3 on boot and then follows the WAL for updates. New price data → new WAL frames → propagated to every region in seconds, without a deploy. The server route that reads it looks like this: // src/routes/api/bars/+server.ts import { getReadOnlyDb } from '$lib/db/shards'; export async function GET({ url }) { const shard = url.searchParams.get('shard')!; // e.g. "equities_1d" const symbolId = Number(url.searchParams.get('s')); const from = Number(url.searchParams.get('from')); const to = Number(url.searchParams.get('to')); const db = getReadOnlyDb(shard); // cached, mmap'd const bars = db .prepare('SELECT ts, o, h, l, c, v FROM bars WHERE sid=? AND ts BETWEEN ? AND ? ORDER BY ts') .all(symbolId, from, to); return new Response(encodeBars(bars), { headers: { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'public, max-age=300, stale-while-revalidate=86400', }, }); } A few things worth calling out: Reads are always local. Every Fly region has its own copy of the shard, so getReadOnlyDb opens a local file. There is no network hop for price data. The response is a compact binary encoding , not JSON. Bars are (u32 ts_delta, i32 ohlc*100, u32 vol) — we lose some precision we don't need and shrink each bar to 20 bytes. Brotli on top gets us another 30–40%. Cache-Control is short but with a long stale-while-revalidate . We don't need instant freshness (price data updates are batched), so the browser can serve a slightly stale response while revalidating in the background. Why SQLite instead of "just a bucket of JSON"? We tried the JSON bucket approach first. It works. But at our access pattern it has two annoying properties: You either pre-slice or you don't. If you pre-slice by (symbol, date range), you either overshoot (waste bandwidth) or undershoot (multiple round trips per chart load). SQLite lets us ask for exactly the bars we need with one query and no round trips. Indexing is not free at request time. If a user says "give me the last 500 bars ending on 2024-06-01," in a JSON world you either load the whole slice and slice client-side, or you build a second index. In SQLite, WHERE ts BETWEEN ... ORDER BY ts is an index lookup and it's over in microseconds. The tradeoff is that SQLite files are opaque — you can't cache them in a CDN by URL the way you can with JSON slices. That's a real cost. It's why the file lives inside the region (on a Fly volume) rather than in front of it (on a CDN). Getting to N regions without going broke Fly.io's model is nice for this shape: you build one image, declare which regions you want to run in, and the same app runs everywhere. Deployment for us is: # fly.toml app = "chartmini" primary_region = "iad" [build] dockerfile = "Dockerfile" [[mounts]] source = "shards" destination = "/data/shards" [[services]] internal_port = 3000 protocol = "tcp" auto_stop_machines = true auto_start_machines = true min_machines_running = 0 Two knobs matter: min_machines_running = 0 means idle regions scale to zero. When a request lands in Tokyo at 3 AM local, Fly boots a machine there in ~300 ms — noticeable for that one user, invisible for everyone after. For a workload this bursty, paying for constantly-warm machines everywhere is a bad trade. The shards mount is where our SQLite files live. Fly volumes are per-region and don't replicate on their own — that's what Litestream is for. Volumes give us the fast local read; Litestream gives us the "always in sync" property. Cold start numbers, roughly: | Scenario | P50 | P99 | |----|----|----| | Warm region, warm mmap | 4 ms | 22 ms | | Warm region, cold mmap | 18 ms | 90 ms | | Cold region (machine boot) | 310 ms | 640 ms | Cold-region requests are ~1% of traffic. We could fix them by keeping machines warm; we chose not to, and instead prefetch the first API call from the client immediately after the HTML shell loads, so the machine warms up while the user is still reading the page. Postgres from the edge: don't be clever This is where a lot of "edge-first" writeups get religious. The honest answer: We keep Postgres exactly as boring as possible. Auth sessions, a saved-runs table, some job state. That's it. Every region hits a primary in one location, plus read replicas for the dashboards, because: The DB is not on the hot path. We touch it on auth, on save, and on a few dashboard reads. Everything else is served from local SQLite. Trying to replicate a transactional DB to the edge for a workload this small is a distraction. Cross-region latency to a single primary is fine when it's 5% of requests, and the code is 100× simpler. When we need low-latency reads of the same value in many regions, we cache it in the app's process memory with an explicit TTL. Not glamorous. Works. If you're building something with meaningfully more DB traffic, look at logical replication or a distributed DB. But for a lot of B2C apps, the "just have one Postgres" answer is still right — as long as it's off the hot path. The SEO side, briefly ChartMini has a content surface — long-form guides, comparison pages, docs. It's Markdown, indexed at build time, and rendered by SvelteKit's server routes with a long ISR-style TTL in front. All of it is cacheable at the CDN. The one non-obvious detail: canonical/hreflang has to be right or your locales cannibalize each other. We generate canonicals and hreflang per-locale in a per-locale layout, using the production origin. Getting this wrong will show up in Search Console as "duplicate, Google chose different canonical" and it will hurt. What we'd do differently Version the shard schema, not just the shard contents. We added a schema_version table after a legitimately painful morning; if you're building anything like this, put it in on day one. Draw the region set with intent, not enthusiasm. More regions doesn't mean more speed — the shard has to fit on the volume, and Litestream has to keep up. Six well-chosen regions beat sixteen sloppy ones. Don't fight the framework for i18n. The built-in per-locale route approach is ugly but stable. Custom middleware isn't worth it. Cost, since everyone asks Order of magnitude, not exact: Fly machines : pennies. Scale-to-zero on the long-tail regions, one always-on machine in the primary region. Fly volumes : paid per GB-month per region. The shards are a few GB each. This is the biggest line item on the "compute" side and it's still small. S3 for Litestream : WAL storage + a small amount of egress. Negligible. Postgres : a single small managed instance + a replica. For an app in this shape, the entire infra bill has one comma in it, not two. Your mileage will vary with traffic — but the shape of the bill is what changes when you push traffic off the "call a service" tier and onto the "read a local file" tier, and that shape change is the point. The takeaway The interesting decisions in a system like this are almost never about the framework. They're about where the byte lives when the request arrives : If the answer is "in memory on this machine" — you win. If the answer is "on disk on this machine" — you probably win. If the answer is "in another region" — you lose, and the question becomes how often you're willing to lose. Get that ordering right and "edge-first" stops being a marketing phrase. It's just where the traffic naturally lives. Written from experience running ChartMini , a candlestick-reading practice tool for traders. If you're building something in the same shape — data-heavy, read-mostly, latency-sensitive — feel free to reach out; happy to compare notes. \
View original source — Hacker Noon ↗


