Cineplan
A TMDB-backed discovery and planning app where the interesting work sits behind the UI — a caching proxy, and scheduled email reminders delivered out of band.
- stack
- React · TypeScript · Express · MongoDB · Redis · BullMQ · Docker
The product is a catalogue browser with a personal library on top. The parts worth talking about are the ones that keep a third-party API and a scheduled-work problem from leaking into the request path.
Proxying TMDB rather than calling it from the client
Every TMDB call goes through /api/tmdb/*. That keeps the API key server-side,
but the more useful consequence is that responses get normalised before they’re
cached. normalizeTitle flattens TMDB’s several shapes — a movie, a series, a
search hit and a recommendation all describe a title differently — into one
predictable object.
So the cache stores small, uniform entries rather than large raw payloads, the client renders a single shape instead of branching per endpoint, and per-resource TTLs can be tuned by how often that resource actually changes. The cache-aside layer sits over the normalised form, not the wire form.
Reminders belong outside the API
A user can schedule any watchlist title for a date and time and get an email when
it’s due. Scheduling writes a BullMQ delayed job into Redis; a standalone
reminder-worker process consumes the queue at the due moment and sends through
Resend.
The API’s job ends when the job is enqueued. The worker re-checks state before sending (the title may have been watched or removed since), marks the reminder sent idempotently, and retries on failure — so a transient email outage doesn’t silently drop a reminder, and a retry doesn’t send a second copy. Because it’s a separate process, reminder delivery can fail, restart, or be scaled without touching request serving.
One Redis, three jobs
Redis backs the session store (connect-redis), the TMDB cache, and the BullMQ
queue. That’s a deliberate consolidation for a single-VM deployment: three
workloads that all want fast in-memory access, sharing one process instead of
three, at the cost of coupling their failure domain — an acceptable trade at this
scale and one that would be worth splitting if any single workload grew.
Front-end details that get skipped
Off-screen images are loading="lazy" and decoding="async", while the detail
hero stays eager because it’s the LCP element. The trailer modal is a real
dialog — role="dialog", aria-modal, Tab trapped inside, Esc and overlay both
close it, background scroll locked, focus restored to the trigger on close. Cards
are operable from the keyboard, and a global :focus-visible ring means you can
always see where you are.