showtimes-crawler/ technical walkthrough
live · /docs ↗

Senior Crawler Developer · technical assessment

A crawler whose shape scales.

A queue-based web crawler built in Nest.js — you POST a URL, a worker fetches and extracts it asynchronously, and every slow, fragile part of scraping is governed centrally. Built for International Showtimes, designed for 25,000+ sites.

Nest.js · TypeScript BullMQ + Redis Cheerio · Puppeteer 43 tests · 8 suites deployed on Render 100% · traumpalast.de
The pipeline — a URL's journey
POST /crawlControllervalidate · enqueue
BullMQQueue (Redis)retries · rate limit
WorkerProcessorrotate UA + proxy
StrategyPageFetcherhttp · browser
CheerioExtraction→ result on job
Field result · reviewer acceptance test

Scored 100% against traumpalast.de — a live German cinema chain, i.e. International Showtimes' own domain. Fetched by the lightweight http engine, no proxy, in 2.19s: redirect followed to the canonical URL, cross-origin CDN assets (static.filmtheaterbetriebe.de) resolved to absolute, German unicode intact, favicon fallback correct. Cheap-first — proven on their home turf.

01 The brief & constraints

What was mandated, and what was left open

The assessment was a two-part specification with the stack named explicitly. That framing decided several things before a line was written — most importantly the language.

PartRequirementMandated tools
Part 1
the crawler
Crawl a given URL and extract title, meta description, favicon, script + stylesheet URLs, image URLs. Queue & process jobs. REST API to start / monitor / cancel. Unit tests. Nest.js · Axios · Cheerio · BullMQ · Redis · Swagger
Part 2
anti-blocking
Headless-browser fetching. Rotating user-agents. Rotating IPs via proxy/VPN, configured by env. Rate limiting. Unit tests. Puppeteer · proxy/VPN · env config

Endpoints (fixed)

POST /crawl · GET /status/:id · DELETE /cancel/:id — named exactly by the spec. Contracts kept intact.

Deadline

Document said 5 working days; recruiter framed it as 3. Treated 3 as the real budget with slack in reserve.

No target site

The crawler takes any URL. So Part 2 is about proving I know the anti-bot mechanisms — not defeating one specific WAF.

The decision the brief forced

My scraping background is primarily Python. But the spec names Nest.js, Axios, Cheerio, BullMQ and Puppeteer by name — the exact tools the team uses. Submitting Python would read as dodging the test. So: build in the required stack, lean on real TypeScript fluency. The scraping concepts transfer; only the syntax was new. Stated plainly, never hidden.

02 Architecture at a glance

Queue-first, not fetch-in-the-request

The single most important decision. Crawling is slow and failure-prone, so it must never run inside the HTTP request. Everything else follows from that shape.

Request path & worker path
POST /crawl ─▶ CrawlController ─▶ CrawlService.enqueue() ─▶ BullMQ queue (Redis)
                                                                   │
                                                                   ▼
GET  /status/:id ◀─ CrawlService.getStatus() ◀───────────  CrawlProcessor (worker)
GET  /crawls     ◀─ CrawlService.list()                     │  1. pick engine
DELETE /cancel/:id ─▶ CrawlService.cancel()                 │  2. rotate UA + proxy
GET  /health     ◀─ QueueHealthIndicator                    │  3. fetch page
                                                            │  4. extract data
                                                            ▼
                                                   PageFetcher (strategy)
                                                   ├─ AxiosFetcher      (http)
                                                   └─ PuppeteerFetcher  (browser)
                                                            │
                                                            ▼
                                                   ExtractionService (Cheerio)

Why the queue earns its place

Throughput is governed centrally — rate limit, concurrency, retries with backoff, and job state all live in one place instead of being reinvented per request. That is exactly what matters going from one site to many.

Three properties it buys for free

Durability — a crashed worker doesn't lose the job. Backpressure — the limiter protects targets and the proxy pool. Horizontal scale — add worker processes, zero code change.

03 Component walkthrough

Every part, and the one job it owns

The codebase is organised by responsibility, not by type. Each unit below does one thing and is testable in isolation.

API layer crawl.controller.ts · dto/

The HTTP surface. Thin controllers; all request/response shapes are decorated DTOs, so Swagger and validation both derive from the same source. A global ValidationPipe (whitelist + transform) rejects bad input before it reaches logic.

WhyUpdate the DTO, not an ad-hoc object — docs can never drift from the contract.

Orchestration crawl.service.ts

Enqueue, status lookup, history listing, cancellation. The only place that talks to the queue. Maps a BullMQ job to the API's status DTO through one shared helper.

WhyOne seam between the domain and BullMQ keeps the queue swappable and the controller dumb.

Worker crawl.processor.ts

The BullMQ consumer. Per job: pick the engine, rotate UA + proxy, fetch, extract, return the result. Honours a worker-level rate limit and cooperative cancellation checkpoints.

WhyAll the slow, fragile work is quarantined here — behind the queue, never in a request.

Fetcher strategy fetcher/

A PageFetcher interface with two implementations — AxiosFetcher (http) and PuppeteerFetcher (browser). The processor chooses per job; nothing downstream knows which ran.

WhyAdding Playwright or a CAPTCHA-solving proxy is a new class, not a rewrite.

Extraction extraction/extraction.service.ts

Pure and I/O-free: extract(html, baseUrl) in, structured data out. All URLs resolved to absolute against the post-redirect URL, de-duplicated; data: / javascript: dropped. Favicon has a real browser-like fallback chain.

WhyNo network inside means it's exhaustively testable against fixtures — where all the fiddly edge cases live.

Anti-blocking anti-blocking/

Small round-robin UserAgentRotator and ProxyRotator fed from env. Empty proxy list ⇒ direct fetch. Rate limiting is the BullMQ limiter, not ad-hoc sleeps.

WhyConfig-driven and composable — the identity strategy is data, not code.

Health health/queue-health.indicator.ts

Terminus probe. Returns 200 only when Redis is reachable AND ≥1 worker is registered — else 503. A queue with no worker silently accepts jobs and never runs them; the probe refuses to call that healthy.

WhyA health check that can't fail on the real failure mode is theatre.

Observability Bull Board · /admin/queues

The standard BullMQ dashboard, read-only, reading the same Redis. Drill into any job: input URL, extracted result, error and retry count.

WhyReal queue visibility in ~15 lines — no bespoke UI to build or maintain.

Cancellation is honest about what's possible

A reviewer will poke this. The semantics are deliberate:

Job stateDELETE /cancel/:id doesResult
waitingRemoved from the queue outright.cancelled 200
activeSets a cancelRequested flag; worker aborts at its next checkpoint (before / after fetch).200 → settles failed "Cancelled by user"
completedNothing — cannot cancel finished work.409 Conflict

No pretending a running fetch can be force-killed. Modelling the real state machine beats faking instant cancellation — and it's exactly the kind of nuance that matters at scale.

04 Project structure

One responsibility per folder

New concerns get their own directory rather than swelling a service. The layout is the documentation.

src/ ├─ main.ts # bootstrap · Swagger · Bull Board · 0.0.0.0:3333 ├─ app.module.ts # Redis/BullMQ wiring · module composition ├─ redis.config.ts # REDIS_URL vs host/port · TLS for rediss:// ├─ crawl/ │ ├─ crawl.controller.ts # the 5 endpoints │ ├─ crawl.service.ts # enqueue · status · list · cancel │ ├─ crawl.processor.ts # BullMQ worker │ ├─ crawl.types.ts │ ├─ dto/ # the API contract (+ Swagger + validation) │ ├─ extraction/ # pure Cheerio HTML → data │ ├─ fetcher/ # PageFetcher · Axios · Puppeteer │ └─ anti-blocking/ # UA rotator · proxy rotator ├─ health/ # Terminus probe (Redis + worker liveness) .claude/ # onboarding: context · rules · memory · agent Dockerfile · render.yaml # multi-stage build · Render blueprint docker-compose.yml # local Redis

8 spec files · 43 offline unit tests. Every new unit ships with a spec; the suite touches no network, Redis or browser.

05 Design decisions

Each choice, and the trade-off it accepts

Every decision here is defensible from first principles — and each one gave something up. Naming the trade-off is how you show it was a decision, not a default.

Queue-first architecture

ChoiceEnqueue & return a job id; work happens in a worker.
Trade-offCallers must poll instead of getting a synchronous result. Worth it: durability, backpressure and horizontal scale.

PageFetcher strategy interface

ChoiceBoth engines behind one interface; processor picks per job.
Trade-offA thin abstraction over two concrete classes. Cheap now, and it's what makes a third engine additive.

Extraction is pure / I/O-free

ChoiceParsing takes already-fetched HTML; no network inside.
Trade-offFetch and parse are two steps, not one. Buys total testability against fixtures.

Results stored on the job (no DB)

ChoiceResult is the job's return value in Redis, 24h retention.
Trade-offNo durable history/analytics. The spec needs none; the upgrade path (Postgres behind a repository) is documented, not faked.

Config over constants

ChoicePorts, timeouts, attempts, limits, proxy list — all env.
Trade-offMore env surface. Pays off the moment it runs anywhere but a laptop.

Docker + system Chromium on Render

ChoiceMulti-stage image, distro Chromium wired to Puppeteer, non-root, dumb-init.
Trade-offHeavier than a native build — but Puppeteer's shared libs are unreliable on Render's native env. Reliability wins.

06 The dashboard question

Why Bull Board — and not a custom dashboard

A dashboard was never requested. When I considered adding visual crawl history, this is the comparison I ran — and the reasoning matters more than the result.

Bull Board chosen

  • ~15 lines; reads the same Redis the worker uses.
  • Real job drill-down: input, result, error, retries, state.
  • Signals fluency with the BullMQ ecosystem — the tool a team actually reaches for.
  • Zero UI to maintain; nothing to get subtly wrong.
  • On-domain for a backend role.

Custom dashboard rejected

  • Hours of React + streaming/polling + a persistence layer for history.
  • New failure surface; a half-polished UI hurts more than none.
  • Scores in a lane the rubric doesn't grade — this is a crawler role.
  • For a senior hire, gilding the spec can read as over-building.
  • Swagger already covers interactive testing.
The principle

Doing exactly the spec cleanly, with good judgement about what to leave out, is itself the senior signal. Bull Board captured the real value (queue visibility) at a fraction of the cost, on the team's own terms.

07 Anti-blocking depth

What's implemented — and how I'd escalate

The assessment target has no serious defenses, so the depth lives in the design and the documented escalation path, not in over-engineering the demo.

Implemented

UA rotation — round-robin pool, per-request. Proxy/VPN rotation — round-robin over an env-configured list, works with any provider or a local VPN gateway; credentials masked in output. Rate limiting — BullMQ worker limiter (N jobs / window) protects both target and proxy pool. Browser engine — Puppeteer for JS-only pages, opt-in per job.

How I'd handle the hard cases

Cloudflare / JS challenges — escalate that domain to the browser engine + residential proxies; the strategy interface already allows it. Akamai / fingerprinting — stealth-patched browser, consistent TLS/JA3 + header/UA coherence, human-like pacing. CAPTCHA — a solver behind the same PageFetcher seam, invoked only on detection.

Adaptive by default

Start every domain on the cheap HTTP engine. When a site starts returning blocks or JS-only shells, auto-escalate that domain to browser + residential proxies. Most of 25k sites never need a browser — you pay for it only where required.

08 Scaling

From one URL to 25,000+ sites

The assessment is a single-URL crawler. International Showtimes tracks 25k+ sites across 120+ markets. The queue-first shape is deliberately the one that gets there — by adding pieces, not rewriting.

Workers scale out

N worker processes against one queue, zero code change. The limiter stays global.

Per-domain politeness

Rate-limit and concurrency per host (a queue/group per domain) so one slow site can't starve others.

Adaptive engine

HTTP by default; auto-escalate to browser + residential proxies only where a domain demands it.

Frontier & dedup

A seen-URL set + scheduling frontier in Redis so 25k sites don't re-crawl or thunder.

Durable store

Postgres for results/history behind a repository interface; Redis stays the live queue + dedup cache. First thing I'd add for production.

Observability

Per-domain success / block / latency rates — Bull Board is already wired; metrics extend it so you see defenses change before data goes stale.

09 Shortcomings & next

What it doesn't do — stated plainly

Honesty over impression. These are known and bounded, documented up front — not discovered after the fact.

first up

No durable persistence

Results & history live in Redis with 24h retention — not an audit log. Postgres behind a repository interface is the documented next step.

design-ready

Single-page crawl only

No link-following / depth. The queue makes depth-N additive (enqueue discovered URLs) — deliberately left out of scope, not blocked.

design-ready

No live CAPTCHA / WAF solving

The escalation path is documented and fits behind the fetcher seam; the target didn't need it, so it wasn't built to be built.

ops

No per-domain rate limiting yet

The limiter is global. Per-host politeness is the key change for true multi-site scale.

ops

Free-tier deploy caveats

Render free instances sleep (30–60s cold start) and are memory-tight for the browser engine. Fine for demo; a paid instance for real load.

testing

No end-to-end / load suite

Unit coverage is strong and offline; I'd add an integration test against ephemeral Redis and a small k6 load profile next.