URL Shortener

Keeping redirects responsive while analytics moves through a separate pipeline

By Viet Nguyen
DjangoReactPostgreSQLRedisKafkaClickHouse

One click, two workloads#

A short-link request has an immediate job: look up a destination and send the browser there. Recording that click is a separate job with different storage and query needs. This project uses that distinction to explore caching, asynchronous processing and the cost of operating a distributed application.

The implementation combines Django, PostgreSQL and Redis for URL creation and lookup, with Kafka and ClickHouse for analytics. The recorded k6 runs reach 292 requests per second at 500 virtual users and 330 at 1,000. Those figures are useful because the accompanying latency measurements expose where adding concurrency stops paying off.

What belongs in the redirect path?#

Writing each click to an analytical store before returning a redirect couples user-visible latency to analytics ingestion. Moving that work into a pipeline reduces that coupling, but it introduces a different question: when can the application honestly say a click has been recorded?

The architecture therefore has two goals to evaluate separately: fast URL resolution and reliable event processing. A successful redirect is not proof that an analytics row has already been stored. Treating these as separate outcomes makes both the monitoring and the failure cases clearer.

Architecture#

architecture-diagram
text
Client → Cloudflare (DDoS/WAF) → Gateway Nginx → Frontend Nginx
                                                          │
                                               Django Backend (4 Gunicorn workers)
                                              /        |         \          \
                                        Redis      PostgreSQL    Kafka    ClickHouse
                                       (Cache+      (OLTP)       │        (OLAP)
                                       Counter+                  │
                                       Metrics)                  ▼
                                                          Kafka Consumer
                                                        (Batch → ClickHouse)

Monitoring: cAdvisor → Prometheus → Grafana | Metrics Sidecar → Dashboard
Logical service flow. Analytics ingestion and querying are separated from URL storage; the diagram does not imply that all dependencies are outside the request path.

Following a redirect through the services#

  • Django looks for the short code in Redis. A cache hit avoids the PostgreSQL lookup.
  • On a cache miss, PostgreSQL supplies the URL mapping and the application fills the cache with a 24-hour expiry.
  • The producer is called to enqueue a click event before the redirect response is returned. The behavior of that call depends on its buffering, acknowledgement and timeout configuration.
  • A separate consumer collects events into batches and writes them to ClickHouse. Analytics queries can then run against that store without using PostgreSQL for the same workload.

Architecture Evolution#

VersionArchitectureLimitation Solved
V1Redis storage and synchronous click trackingExplore the redirect path; durability depends on persistence configuration.
V2PostgreSQL URL storage with Kafka-based analyticsMove analytics work away from the immediate redirect, while queries still share database resources.
V3ClickHouse analytics with buffered ingestionSeparate analytical queries from URL lookups, at the cost of more services and eventual consistency.

Why use a counter for short codes?#

An incrementing Redis counter supplies an integer that is encoded as Base62. The attraction is operational simplicity: requests using the same counter receive distinct increments rather than generating random candidates and retrying collisions.

That property depends on preserving the counter and coordinating its use. Resetting it while old mappings still exist can reuse identifiers. Sequential identifiers are also predictable, so a short code must not be treated as an access-control secret. The useful claim is uniqueness within the maintained allocation scheme, not an unconditional guarantee under every recovery scenario.

Code: 2-Phase Redirect#

views.py
python
def redirect_url_view(request, short_code):
    cache_key = f"url:{short_code}"

    # Phase 1 (SYNCHRONOUS) — check Redis cache first
    original_url = cache.get(cache_key)
    if original_url:
        MetricsMiddleware.record_cache_hit()
        # Phase 2 (ASYNC) — fire-and-forget Kafka event
        redirect_producer.produce_redirect_event(
            short_code=short_code,
            timestamp=timezone.now().isoformat(),
            user_agent=request.META.get('HTTP_USER_AGENT', ''),
            ip=request.META.get('REMOTE_ADDR', '')
        )
        return redirect(original_url)

    # Cache miss → PostgreSQL with exponential backoff retry
    for attempt in range(3):
        try:
            url_obj = URL.objects.get(short_code=short_code)
            MetricsMiddleware.record_cache_miss()
            cache.set(cache_key, url_obj.original_url, timeout=86400)
            redirect_producer.produce_redirect_event(...)
            return redirect(url_obj.original_url)
        except OperationalError:
            delay = 0.05 * (2 ** attempt) + random.uniform(0, 0.01)
            time.sleep(delay)
Abbreviated project excerpt showing the control flow. Imports, surrounding configuration and error handling are omitted.

The delivery boundary between Kafka and ClickHouse#

The consumer excerpt commits offsets after a successful batch insert. If insertion fails, keeping the offsets uncommitted leaves an opportunity to retry. But if insertion succeeds and the process fails before committing, the same records may be processed again. This is the gap where duplicate handling matters.

Exactly-once behavior across an external database requires more than manual commits. This portfolio does not present a verified deduplication protocol or failure-injection results. The architecture supports a discussion of retries and replay; it does not establish loss-free delivery under all failures.

Code: Kafka → ClickHouse Pipeline#

run_kafka_consumer.py
python
# Consumer: auto_commit=False, max_poll_records=500
event_buffer = []
flush_interval = 2.0  # seconds
max_buffer_size = 500

for message in consumer:
    event = message.value
    if event.get('short_code'):
        event_buffer.append({
            'short_code': event['short_code'],
            'timestamp': event.get('timestamp'),
            'user_agent': event.get('user_agent', ''),
            'ip': event.get('ip', ''),
        })

    # Flush on time window OR buffer full
    if time.time() - last_flush > flush_interval or len(event_buffer) >= max_buffer_size:
        if flush_to_clickhouse(event_buffer):  # retry with exponential backoff
            consumer.commit()   # manual offset commit ONLY after successful write
            event_buffer.clear()
Abbreviated project excerpt showing the control flow. Imports, surrounding configuration and error handling are omitted.

A timer condition is not a timer#

The excerpt checks the flush interval inside the message loop. That distinction matters when traffic becomes quiet: a partially filled buffer does not get another time check until control returns to the condition. A complete consumer needs an idle-time flush mechanism or an equivalent polling design, plus a shutdown policy.

Batching trades fewer database writes for a delay before events become queryable. Buffer length, time since the last successful flush and consumer lag are useful follow-up measurements because they describe that delay directly. Request success rate alone cannot show an analytics pipeline falling behind.

Documented container limits#

ServiceCPU LimitMemory Limit
Backend (Gunicorn)1.0512 MB
PostgreSQL1.0512 MB
Kafka1.01 GB
ClickHouse1.01 GB
Redis0.5256 MB
Kafka Consumer0.5256 MB
Frontend (Nginx)0.5128 MB
Metrics Sidecar0.2564 MB

Performance Results#

Metric500 VUs1,000 VUs
Total Requests90,447101,516
Throughput292 req/s330 req/s
Error Rate0%0%
Median Latency575 ms1,330 ms
p95 Latency1,610 ms2,850 ms

What the load tests actually show#

Doubling virtual users increases throughput by about 13%, while median latency rises from 575 to 1,330 milliseconds and p95 from 1,610 to 2,850 milliseconds. The system completes more work, but each user waits longer. The recorded zero error rate is encouraging for those runs; it does not make the longer wait disappear.

This pattern suggests saturation and queueing somewhere along the tested path. Pinpointing the limiting service requires time-aligned request and infrastructure measurements. An earlier CPU note in this case study referred to a two-core backend limit, while the configuration table lists one core, so that note cannot establish the bottleneck for this configuration.

What a reproducible rerun should include#

The earlier 1–2 millisecond cache-path estimate is not comparable to the end-to-end k6 latency distribution. A rerun should record the exact request mix, cache warm-up, test duration, load-generator location, service configuration and commit. It should distinguish redirect latency from the delay until a click appears in analytics.

The resource table describes configured limits for the listed services, not measured consumption or a complete bill for every monitoring container. A cached redirect benchmark also does not establish the capacity of URL creation, cache-miss lookups or expensive analytics queries.

Code: Atomic Metrics Middleware#

middleware.py
python
# Lua script: atomically increment a key and set TTL only on first creation
_INCR_WITH_TTL_LUA = """
local val = redis.call('INCR', KEYS[1])
if val == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return val
"""

class MetricsMiddleware:
    def __call__(self, request):
        start = time.perf_counter()
        response = self.get_response(request)
        latency_ms = (time.perf_counter() - start) * 1000

        with self._redis.pipeline() as pipe:
            # RPS counter for this exact second
            self._atomic_incr(pipe, f"metrics:rps:{second_key}", TTL_RPS)
            # Latency sorted set (score=timestamp, member=latency_ms)
            pipe.zadd(latency_key, {f"{latency_ms:.3f}": timestamp_ms})
            pipe.zremrangebyrank(latency_key, 0, -(MAX_SAMPLES + 1))
            # Error counters (only 5xx)
            if status_code >= 500:
                self._atomic_incr(pipe, f"metrics:errors:5xx", TTL_CUMULATIVE)
            pipe.execute()
        return response
Abbreviated project excerpt showing the control flow. Imports, surrounding configuration and error handling are omitted.

Instrumentation has its own data model#

The metrics excerpt uses a formatted latency value as a Redis sorted-set member. Equal formatted values share a member, so repeated measurements can overwrite one another rather than count as separate observations. That can affect a percentile calculation built from the set. A production revision should give each observation a unique identity or use a suitable histogram.

This is a useful reminder when comparing a custom dashboard with a load generator: first check whether they measure the same requests, time window and aggregation. A mismatch is a reason to inspect the measurement path before tuning the application.

URL Shortener: URL shortening page, real-time monitoring dashboard, Grafana infrastructure metrics (grafana.vietnn.id.vn), and Portainer container management (portainer.vietnn.id.vn). (image 1)
URL shortening page, real-time monitoring dashboard, Grafana infrastructure metrics (grafana.vietnn.id.vn), and Portainer container management (portainer.vietnn.id.vn).
URL Shortener: URL shortening page, real-time monitoring dashboard, Grafana infrastructure metrics (grafana.vietnn.id.vn), and Portainer container management (portainer.vietnn.id.vn). (image 2)
URL Shortener: URL shortening page, real-time monitoring dashboard, Grafana infrastructure metrics (grafana.vietnn.id.vn), and Portainer container management (portainer.vietnn.id.vn). (image 3)
URL Shortener: URL shortening page, real-time monitoring dashboard, Grafana infrastructure metrics (grafana.vietnn.id.vn), and Portainer container management (portainer.vietnn.id.vn). (image 4)

Tech Stack#

TechnologyRole
Django / DRFREST API & URL routing (4 Gunicorn workers)
PostgreSQL 15OLTP — URL mappings only, never updated after creation
RedisL1 cache (24h TTL), atomic counter (INCR), real-time metrics via Lua scripts
Apache KafkaEvent streaming — fire-and-forget from redirect, manual offset commits
ClickHouseOLAP analytics — MergeTree engine, monthly partitions, 1-year TTL
React 18 / ViteDashboard with real-time metrics, 5s polling, 60-point history
Prometheus + GrafanaInfrastructure monitoring — cAdvisor scrapes at 5s, pre-provisioned dashboards
Docker Compose11-service orchestration with health checks and resource limits
k6Load testing up to 1,000 VUs with Grafana dashboards

When this architecture earns its complexity#

The project makes the trade-offs visible: a cache changes lookup cost, an event stream changes when work completes, and a separate analytical database changes where queries consume resources. Each addition introduces a new state to observe and a new failure to test. The next improvement should be chosen from measured latency or delivery behavior, rather than adding another service by default.

References and another engineering case study#