Build1 publisher3 min readPublished
Four REST calls became one query: the sidecar pattern, minus the marketing
A read-only GraphQL service over an existing SQLite file cut a four-request search screen down to one. The interesting parts are DataLoader, cost limits and a query_only pragma.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened
- The DailyWatch Android client painted one search screen with four HTTP requests: GET /api/search?q=, then GET /api/channels?ids=, then GET /api/categories, then a per-video GET /api/video/{id} when the user tapped a card.
- The fix was a read-only GraphQL sidecar built with Strawberry and FastAPI, pointed at the same SQLite file the PHP site already reads.
- Every one of those endpoints returned the whole row out of SQLite, 41 columns for a video, and the client rendered six of them.
- On a throttled 3G profile the screen took 2.4 s at p95 to first meaningful paint, and about 180 KB of JSON crossed the wire to draw something that genuinely needs 20 KB.
- 180 KB transferred against a stated need of 20 KB is about nine times the payload the UI consumes.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A search screen on DailyWatch's Android client used to need four HTTP requests to draw one list: a search call, a channels-by-ids call, a categories call, then a per-video call as soon as the user tapped a card [1]. The fix the author landed was not a rewrite but a read-only GraphQL sidecar in Strawberry and FastAPI opened against the same SQLite file the existing PHP site reads [2]. The numbers are the argument. Every one of those REST endpoints returned the full row, 41 columns for a video, of which the client rendered six [3]. On a throttled 3G profile that screen took 2.4 s at p95 to first meaningful paint and moved roughly 180 KB of JSON to draw something the author puts at 20 KB of actual need [4]. That is about nine times the payload the UI consumes [5]. Latency chaining did the rest: request two could not start until request one returned the channel IDs, so two serial round trips on a 250 ms mobile connection burned half a second before any pixel moved [6]. The failure was composition, not endpoints. The author is explicit that each endpoint was individually fine [7], and that /api/channels?ids=a,b,c was a hand-rolled DataLoader with no schema, no batching contract, and a URL length limit [8]. Four responses also meant four TTLs and four invalidation paths, and when the cron job refreshed trending videos one of the four consistently went stale in a way that made the UI look broken [9]. Adding a field to the search response had already broken an older client doing strict JSON decoding, with no way to tell which fields were in use [10]. Note what did not change. The site is a PHP 8.4 monolith behind LiteSpeed with a page cache, server-rendered, no hydration, SQLite reads in single-digit milliseconds, and the author says none of it needed touching [11]. The apps were paying for a data model shaped around HTML pages [12]. The reason to put GraphQL beside the monolith rather than inside it is operational. Because the read model is a file, a second process can open the same database read-only in WAL mode with no replica, no pool, and no network hop to the data [13]. If the sidecar dies the website keeps serving HTML and only the apps degrade, to a cached view; a GraphQL layer inside the monolith would share a fatal error handler with the revenue path [14]. Strawberry being code-first means pyright catches a resolver returning the wrong shape at build time, where an SDL file drifts from its resolvers inside about two sprints [15]. And the access pattern is many small indexed reads rather than one large query, which batches well [16]. The author also prices the sidecar honestly: one more deploy target, one more origin behind Cloudflare, one more thing to monitor, and a hard invariant that the service never writes, enforced with PRAGMA query_only = 1 on every connection rather than trust [17]. That pragma is the load-bearing line in the whole design. The remaining work listed is the unglamorous part and the reason to read the piece: schema design against an FTS5 index, keyset pagination over bm25 scores, killing the N+1 with DataLoader, cost limits, and making a POST-shaped protocol cacheable at the CDN edge [18]. One detail shows how far the shared surface has to reach. Full-text query construction had to be shared rather than forked, and the existing PHP sanitiser strips anything that is not a letter, number or whitespace before capping input at eight tokens, because FTS5 treats characters like " * : ^ - AND OR NOT as syntax while users type them as text [19].