This is a writeup of the architecture behind Cheonmyeongdang, a Korean Saju (Four Pillars of Destiny) reading app. We deployed on Cloudflare Workers, Pages, and D1. These are the technical decisions and what we learned.
Why Cloudflare
The target audience for a Korean astrology app is concentrated in Korea, Japan, and the Korean diaspora in the US. We needed:
- Low latency to Asia-Pacific users
- Serverless compute (traffic is spiky and unpredictable)
- A database that could sit close to compute
- Cost structure that does not blow up with idle time
Cloudflare's edge network has 300+ data centers globally, including Seoul, Tokyo, and Singapore. Requests from Seoul typically hit a local PoP. That was the primary driver.
Architecture Overview
User Request
|
v
Cloudflare Pages (static HTML/JS/CSS)
|
v
Cloudflare Workers (API handlers)
| |
v v
D1 Database KV Namespace
(user records, (session tokens,
purchase log) rate limit state)
All compute runs in Workers. The Pages site is static — HTML, CSS, and client-side JS. The two communicate through fetch calls to /api/* routes, which Pages routes to Workers via the _routes.json file.
The Calendar Engine in a Worker
The core computation — converting a Gregorian birth date to Four Pillars — is CPU-bound, not I/O-bound. Workers run on V8 isolates with a 10ms CPU time limit on the free tier (50ms on paid). Our engine for a single birth date runs in under 2ms, which fits comfortably.
The engine is pure JavaScript with no external dependencies:
// Worker handler
export default {
async fetch(request, env) {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
const body = await request.json();
const { year, month, day, hour } = body;
// Input validation
if (!year || !month || !day) {
return new Response(JSON.stringify({ error: 'missing_fields' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Calendar engine (pure computation, no I/O)
const pillars = computeFourPillars(year, month, day, hour ?? -1);
return new Response(JSON.stringify(pillars), {
headers: { 'Content-Type': 'application/json' }
});
}
};
The key constraint is that Workers cannot access the filesystem. All lookup tables (lunisolar conversion, solar term boundaries) must be embedded as JavaScript objects, not loaded from disk.
D1 for User Records
D1 is Cloudflare's serverless SQLite-compatible database. It is regional, not global, so we chose the wnam region (US West) as a compromise between Asia-Pacific and North America latency. The schema is simple:
CREATE TABLE users (
email TEXT PRIMARY KEY,
name TEXT,
phone TEXT,
created_at INTEGER NOT NULL,
lang TEXT DEFAULT 'ko'
);
CREATE TABLE purchases (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
sku TEXT NOT NULL,
amount INTEGER NOT NULL,
currency TEXT NOT NULL,
paid_at INTEGER NOT NULL,
FOREIGN KEY (email) REFERENCES users(email)
);
D1 queries from a Worker look like standard SQL with prepared statements:
async function getUser(env, email) {
const row = await env.DB.prepare(
'SELECT * FROM users WHERE email = ?'
).bind(email).first();
return row;
}
async function recordPurchase(env, purchase) {
await env.DB.prepare(
'INSERT INTO purchases (id, email, sku, amount, currency, paid_at) VALUES (?, ?, ?, ?, ?, ?)'
).bind(
purchase.id,
purchase.email,
purchase.sku,
purchase.amount,
purchase.currency,
Math.floor(Date.now() / 1000)
).run();
}
KV for Session Tokens
Payment sessions need temporary storage: a token is created when the user starts checkout, used once when the payment provider calls our webhook, then expired. KV is a good fit for this — it is globally distributed, has TTL support, and the latency for reads is low.
// Store a payment session token for 30 minutes
await env.KV.put(`session:${token}`, JSON.stringify(sessionData), {
expirationTtl: 1800
});
// Consume it once
const raw = await env.KV.get(`session:${token}`);
if (!raw) return new Response('expired', { status: 410 });
await env.KV.delete(`session:${token}`);
const session = JSON.parse(raw);
Pages Functions vs Standalone Workers
We started with Pages Functions (functions/api/ directory) because it co-locates the API code with the frontend. The issue: Pages Functions are deployed as part of the Pages project, so a failed function deployment fails the whole site deployment.
We split critical payment logic into a standalone Worker (wrangler.toml project) and kept lightweight handlers in Pages Functions. The rule we landed on:
- Pages Functions: serving static configs, non-critical API endpoints
- Standalone Worker: payment webhook handling, purchase record writes
This also makes it possible to deploy payment logic changes independently of frontend changes.
Edge Caching for Static Pages
The Saju reading pages are server-rendered at request time (we need to inject the actual pillar values), but the surrounding layout and supporting pages are cacheable. We set cache headers explicitly:
// Cache static blog pages at the edge for 1 hour
return new Response(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600'
}
});
// Never cache personalized reading results
return new Response(readingHtml, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store'
}
});
Lessons Learned
1. Workers CPU time limit is tighter than it looks.
The 10ms limit counts CPU execution time, not wall time. Any synchronous computation loop you assume is fast needs to be measured. Our calendar engine runs in 2ms, but during development an early version with a more naive solar-term binary search was hitting 8ms.
2. D1 is regional, not global.
If your users are split between Korea and the US, you will get asymmetric latency. Reads from the non-colocated region take noticeably longer. For a read-heavy app where personalization is not critical, KV is often better — it is globally distributed.
3. Deploy the functions, not just the pages.
When you run wrangler pages deploy, verify with curl that your API routes return JSON, not HTML. Pages Functions can silently fail to upload if the functions directory is missing or the _routes.json config is wrong, and the site deploys successfully while all API calls 404. We added a post-deploy check that curls the payment config endpoint and asserts Content-Type: application/json.
4. wrangler.toml project name must match exactly.
The project name in wrangler.toml and the actual Pages project name must be identical, with no auto-generated suffixes. If Cloudflare adds a suffix (like -4fb) when you create the project via the dashboard, your wrangler deploy will either fail or create a second project.
What We Would Do Differently
If we started today:
- Start with a standalone Worker instead of Pages Functions for the API layer. The co-location convenience is not worth the coupled deployment.
- Use D1 for all persistent state from day one instead of migrating from KV partway through.
- Add post-deploy API validation to the deployment script immediately, not after the first silent failure.
The App
If you want to see the architecture in production, Cheonmyeongdang is running on this stack today. The reading engine computes the Four Pillars from a birth date, runs the Five Elements distribution, and returns a full interpretation — all within a single Worker round trip from Seoul.
The Saju API itself (https://saju-api.pages.dev) is also on this stack and offers 100 free calls per month if you want to build on top of the calendar engine directly.
Questions about the Cloudflare architecture or the Workers constraints? Drop them below.
Top comments (0)