ReadThat 6: Backend Architecture
A stateless Cloudflare Worker over D1, R2, Images, Stream, and Durable Objects: auth and ACLs, signed keyset feed cursors, direct-creator media uploads, HLS video that starts instantly, and read-your-writes consistency on a distributed SQLite.
Part 6 of the ReadThat case study.
📲 Try it live: Download the ReadThat APK (8.8 MB, Android 8+). Open the file on your phone and allow “install unknown apps” if prompted.
Topology
One Worker serves everything: the PWA as static assets, and /v1/* + /health as the API. Stateless request orchestration over managed primitives:
mobile client -> Worker REST API -> D1 (users/posts/comments/votes/ACL/outboxes) | -> Images (responsive image delivery, signed variants) | -> Stream (direct video upload + ABR HLS/DASH) | -> R2 (staging/fallback objects) └------- -> Durable Objects (post event sequence/WebSocket, distributed rate limits)The whole deployment is one config file: every binding visible at a glance: wrangler.jsonc. D1 for relational truth, R2 for bytes, Images/Stream for delivery, two Durable Object classes (PostRoom, RateLimiter), and two Analytics Engine datasets for telemetry.
Why this stack for a sample that wants to behave like production: zero servers to patch, per-request pricing that rounds to zero at portfolio scale, and, more interestingly, the same constraints real distributed systems have. D1 is SQLite at the edge with session consistency, not a monolithic Postgres, so read-your-writes is something you must engineer, not assume.
Auth and ACLs
- Passwords: salted + deployment-peppered hashing; tokens: opaque, stored hashed, with rotating refresh tokens and revocation (auth.ts, crypto.ts).
- On the client, tokens live under a non-exportable Android Keystore AES-GCM key.
- Communities carry real roles: owner, moderator, approved member, subscriber, banned, across public/restricted/private subreddits. Every read path is ACL-filtered: feed, search, media (access.ts).
- Authorization is re-checked at commit time: a successful media upload is not authorization to publish the post that references it.
The feed query: signed keyset cursors
Feed pagination is the backend’s most interesting contract. Cursors are signed, viewer-bound keysets over (snapshot time, personalized rank, post id), never offsets:
const encodedCursor = context.url.searchParams.get("cursor");let cursor: FeedCursor | null = null;if (encodedCursor) { cursor = await verifyOpaquePayload<FeedCursor>(context.env.CURSOR_SECRET, encodedCursor); if (!cursor || cursor.version !== 2 || cursor.subreddit !== subreddit || cursor.audience !== audience || ...) { throw new AppError(400, "invalid_cursor", "Feed cursor is invalid or belongs to another feed"); }}const snapshotAt = cursor?.snapshotAt ?? Date.now();The ranked walk unions subscribed content (boosted by a rank offset) with discovery, then pages by (rank, id) strictly below the cursor (feed.ts#L228-L285). Properties that matter:
- Snapshot admission:
snapshotAtfreezes which posts can enter the page walk, so pagination doesn’t duplicate or skip as new posts land. - Viewer-bound signature: replaying a cursor under another account or scope returns
400 invalid_cursor; the client can’t decode it, only echo it. - Keyset, not offset: no drift when rows are inserted ahead of the reader, and each page is an indexed range scan (migration 0002).
- Cursor pagination never cuts through a feed group, and feed payloads carry only render cells, no detail-only fields.
The MediaFeed projection (GET /v1/feeds/media) shares this ranking and ACL policy; it’s a separate typed endpoint, not a separate ranking service.
Media: upload, serving, and instant HLS
Uploads bypass the Worker. Images and video use direct-creator-upload flows (R2 resumable single/multipart staging, Cloudflare Images ingest, Stream direct upload), so large bodies never buffer in Worker memory. The Worker validates ownership and ACL when the post is committed. Limits enforced on both sides: 20 MB/image, 100 MB/video, 20-photo galleries.
Images get signed 1080/2048 px variants; the client’s cache key is the content identity, not the expiring signature, so signature rotation never cold-starts the cache. Avatars store an owned media ID in D1, never a signed URL.
Video goes to Stream for ABR HLS/DASH. Two details bought the most user-visible quality:
Poster frames that aren’t black. Stream’s default 0-second thumbnail is commonly a fade-in frame. The Worker picks a representative early timestamp and normalizes every poster URL, with exact-aspect crops so the poster’s geometry matches the player frame that replaces it:
/** Pick an early representative frame without using Stream's 0s default, which is commonly a black/fade-in frame. */export function streamThumbnailTimestampPct(durationSeconds: number | null): numberFeed payloads carry the full delivery ladder: hlsUrl, dashUrl, posterUrl, previewUrl, fallbackUrl, plus deliveryStatus/processingProgress (Wire.kt Video cell): so the client starts HLS with zero extra round trips: manifest from the CDN, first segments often already in the shared Media3 cache from prefetch (part 3). While Stream is still processing, the status fields let the feed render an honest progress state instead of a broken player.
R2 fallback reads support conditional requests, HEAD, and byte ranges: enough for a player to seek against staging objects before transcoding completes.
Consistency on a distributed SQLite
- Read-your-writes via D1 Sessions: the Worker returns an
X-D1-Bookmarkheader; clients echo it, so a user who just posted sees their post on the next read even if it’s served by a different D1 replica. The AndroidBackendClientpropagates bookmarks automatically. - Idempotent mutations: every write carries a client-minted mutation ID kept in a ledger; exact retries replay the original result, UUID reuse with changed input gets
409. - Transactional aggregates: vote/comment counts update in the same transaction as the ledger row.
- WebSockets are invalidation, not truth: each post’s
PostRoomDurable Object hands out a monotonic sequence with bounded replay and gap detection; reconnect always reconciles from REST + Room (post-room.ts). - Rate limiting is a Durable Object, so limits are consistent across colos rather than per-isolate (rate-limiter.ts).
Verification
The suite runs inside the Workers runtime with real local D1/R2/DO bindings: 24 integration scenarios (api.test.ts), plus HTTP smoke scripts that exercise registration, ACLs, posting, a range media read, twelve nested comment levels, idempotent voting, and the SDUI contract against any deployed origin (scripts/). Deterministic licensed fixtures seed real content, including a 1,883-comment forest for the comments work.
📷 Diagram placeholder: request path for a video post, upload → Stream → HLS → client cache.