I run a small booking system for shops in Malaysia: barbers, car washes, massage therapists. Each shop gets one link, and customers pick a service, a staff member, a day and a time. It runs entirely on Cloudflare Workers + D1 with Hono, and the whole app is about 1,700 lines of TypeScript.
Here are the four parts that took the most thought, with the actual code.
1. "Today" in the business's time zone, without a date library
A shop in New York and a shop in Kuala Lumpur need different answers to "which slots are left today?". Workers support the full Intl API, so this is all you need:
export function nowIn(tz: string): { today: string; minutes: number } { const parts = Object.fromEntries( new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).formatToParts(new Date()).map((p) => [p.type, p.value]), ); return { today: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute), }; } Dates are stored as YYYY-MM-DD strings and times as HH:MM, both in the shop's local time. String comparison then works for ranges (date BETWEEN ? AND ?), and there are no UTC conversions to get wrong.
2. The slot engine is a pure function
Everything about availability lives in one function with no I/O, which makes it easy to unit test:
for (let t = open; t + duration <= close; t += step) { if (date === today && t <= nowMin) continue; // past times const end = t + duration; if (busyAll.some((x) => overlaps(t, end, x))) continue; // breaks, blocked time for (const sid of staffIds) { // [null] = single chair const clash = bookings.some((b) => (sid === null || b.staff_id === sid || b.staff_id === null) && overlaps(t, end, b)); if (!clash) { out.set(toHHMM(t), sid); break; } // first free staff wins } } "Anyone available" just means passing every staff id. The function returns the first free one, which becomes the booking's assigned staff.
The month calendar calls the same function once per day to mark full days. For a 31-day month that's 31 pure calls over data loaded in two queries, which is fast enough that I never needed caching.
3. Preventing double bookings without locks
D1 is SQLite, and requests can't hold a transaction open between "check" and "insert". So I insert first, then check whether I lost the race:
const booking = await db.prepare(`INSERT INTO bookings (...) VALUES (...) RETURNING *`).bind(...).first(); const clash = await db.prepare( `SELECT id FROM bookings WHERE shop_id = ? AND date = ? AND status = 'confirmed' AND id < ? AND (? IS NULL OR staff_id IS NULL OR staff_id = ?) AND ? < start_minutes + duration_min AND start_minutes < ?`, ).bind(...).first(); if (clash) { await db.prepare("UPDATE bookings SET status = 'cancelled' WHERE id = ?").bind(booking.id).run(); return renderBooking(c, shop, "Sorry, someone just took that time. Please choose another."); } The rule is "the lower id wins". If two people press Confirm at the same instant, both inserts succeed, but only the second one finds an earlier overlapping row, so only the second is cancelled. Nobody ends up double-booked.
4. Stripe without the SDK
The Stripe Node SDK works on Workers now, but for one Checkout Session and one webhook, plain fetch is smaller and easier to read:
const res = await fetch("https://api.stripe.com/v1/checkout/sessions", { method: "POST", headers: { Authorization: `Bearer ${env.STRIPE_SECRET_KEY}` }, body: new URLSearchParams({ mode: "payment", "line_items[0][quantity]": "1", "line_items[0][price_data][currency]": "usd", "line_items[0][price_data][unit_amount]": "1900", "line_items[0][price_data][product_data][name]": "1 month", success_url: `${origin}/billing/return?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${origin}/dashboard/billing`, }), }); Webhook verification is an HMAC with crypto.subtle:
const [t, v1] = parse(header); // "t=...,v1=..." const expected = await hmacHex(env.STRIPE_WEBHOOK_SECRET, `${t}.${payload}`); const ok = Math.abs(Date.now() / 1000 - t) < 300 && timingSafeEqual(v1, expected); Even with a valid signature, I re-fetch the session and check payment_status === "paid" before extending an account. The return URL does the same, so whichever arrives first wins, and the UPDATE ... WHERE status = 'pending' RETURNING id guarantees the account is only extended once.
Bonus: a public demo that heals itself
The live demo has a scheduled() handler on an hourly cron. It deletes the demo business and re-seeds it in one db.batch(), using relative dates (date('now', '+1 days')) so there are always upcoming bookings. Visitors can cancel everything and it's back within the hour.
[triggers] crons = ["0 * * * *"] Try it
- Live demo (resets hourly): https://slotdesk-demo.fauzyamy.workers.dev/demo-barber. Owner login is
demo@example.com/demo1234. - I've packaged the whole thing (booking pages, staff, reviews, Stripe billing, admin, docs, tests) as a starter kit called SlotDesk: https://fauzy45.gumroad.com/l/slotdesk
If you've built booking or scheduling on D1 differently, I'd love to hear how you handled races and time zones.
