← Back to DevBytes

Designing a URL Shortener with Microservices Architecture

Understanding the URL Shortener

A URL shortener transforms long, cumbersome web addresses into compact, easy-to-share links. Think of services like bit.ly or TinyURL — they accept a long URL and return a short alias like https://short.ly/abc123. When someone clicks the short link, they are redirected to the original destination. This simple concept becomes a fascinating system design challenge when you need to handle millions of links, high traffic, analytics, and reliable redirections.

What It Is

At its core, a URL shortener consists of two main operations:

Additional features often include click analytics, expiration dates, custom aliases, and user dashboards.

Why Microservices Matter Here

Building a URL shortener as a monolithic application is possible, but as traffic grows and requirements evolve, a microservices architecture offers clear advantages:

High-Level Microservices Architecture

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

We decompose the system into several focused services connected via lightweight protocols (REST, gRPC, or asynchronous messaging).

Service Breakdown

Communication Patterns

We mix synchronous and asynchronous flows:

Implementing the Services (with Code Examples)

Let's walk through a practical implementation using Python and common libraries. The principles apply regardless of language.

Shortening Service

This service exposes a REST endpoint POST /api/shorten. It receives a JSON body with { "long_url": "..." }, generates a unique slug, stores the mapping in PostgreSQL, and returns the short URL.

Slug generation strategy: We use a Base62 encoding of a counter or a cryptographic hash to keep the short code small and unique. For simplicity, we'll generate a random 7-character alphanumeric string and handle collisions by checking uniqueness before inserting. In production, a distributed ID generator like Snowflake or a dedicated counter service works better.


# shortening_service/app.py
from flask import Flask, request, jsonify
import psycopg2
import random
import string
import os
from kafka import KafkaProducer
import json

app = Flask(__name__)

DB_CONFIG = {
    'host': os.getenv('DB_HOST', 'localhost'),
    'database': os.getenv('DB_NAME', 'url_shortener'),
    'user': os.getenv('DB_USER', 'user'),
    'password': os.getenv('DB_PASSWORD', 'pass')
}

KAFKA_BOOTSTRAP = os.getenv('KAFKA_BOOTSTRAP', 'localhost:9092')
producer = KafkaProducer(bootstrap_servers=KAFKA_BOOTSTRAP,
                         value_serializer=lambda v: json.dumps(v).encode('utf-8'))

BASE_URL = os.getenv('BASE_URL', 'https://short.ly/')

def get_db_connection():
    return psycopg2.connect(**DB_CONFIG)

def generate_slug(length=7):
    chars = string.ascii_letters + string.digits  # 62 chars
    return ''.join(random.choice(chars) for _ in range(length))

def is_slug_unique(slug, conn):
    with conn.cursor() as cur:
        cur.execute("SELECT 1 FROM mappings WHERE slug = %s", (slug,))
        return cur.fetchone() is None

@app.route('/api/shorten', methods=['POST'])
def shorten():
    data = request.get_json()
    long_url = data.get('long_url')
    if not long_url:
        return jsonify({'error': 'Missing long_url'}), 400

    conn = get_db_connection()
    try:
        # Generate unique slug with collision retry
        slug = generate_slug()
        retries = 0
        while not is_slug_unique(slug, conn) and retries < 5:
            slug = generate_slug()
            retries += 1
        if retries == 5:
            return jsonify({'error': 'Could not generate unique slug'}), 500

        with conn.cursor() as cur:
            cur.execute(
                "INSERT INTO mappings (slug, long_url) VALUES (%s, %s)",
                (slug, long_url)
            )
        conn.commit()

        # Publish event
        event = {
            'type': 'link.created',
            'slug': slug,
            'long_url': long_url,
            'timestamp': str(datetime.utcnow())
        }
        producer.send('url_events', value=event)

        short_url = BASE_URL + slug
        return jsonify({'short_url': short_url, 'slug': slug}), 201
    except Exception as e:
        conn.rollback()
        return jsonify({'error': str(e)}), 500
    finally:
        conn.close()

if __name__ == '__main__':
    app.run(port=5001)

The service uses PostgreSQL for durability and Kafka for notifying other services. In a real deployment, we'd add idempotency checks (if the same long URL already exists, return the existing slug), and a distributed ID generator to avoid random collisions at scale.

Database Schema for Mappings

A simple PostgreSQL table supports both shortening and redirect lookups. Add indexes on slug and long_url.


CREATE TABLE mappings (
    id SERIAL PRIMARY KEY,
    slug VARCHAR(10) UNIQUE NOT NULL,
    long_url TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_slug ON mappings (slug);
CREATE INDEX idx_long_url ON mappings (long_url);

Redirect Service

The redirect service is a high-performance HTTP handler that resolves slugs. It first checks Redis, then falls back to PostgreSQL, and issues a 301 permanent redirect. It also emits a click event asynchronously.


# redirect_service/app.py
from flask import Flask, request, redirect, abort
import redis
import psycopg2
import os
from kafka import KafkaProducer
import json
from datetime import datetime

app = Flask(__name__)

REDIS_HOST = os.getenv('REDIS_HOST', 'localhost')
REDIS_PORT = int(os.getenv('REDIS_PORT', 6379))
cache = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)

DB_CONFIG = {
    'host': os.getenv('DB_HOST', 'localhost'),
    'database': os.getenv('DB_NAME', 'url_shortener'),
    'user': os.getenv('DB_USER', 'user'),
    'password': os.getenv('DB_PASSWORD', 'pass')
}

KAFKA_BOOTSTRAP = os.getenv('KAFKA_BOOTSTRAP', 'localhost:9092')
producer = KafkaProducer(bootstrap_servers=KAFKA_BOOTSTRAP,
                         value_serializer=lambda v: json.dumps(v).encode('utf-8'))

def get_db_connection():
    return psycopg2.connect(**DB_CONFIG)

def publish_click_event(slug, referrer, user_agent):
    event = {
        'type': 'click.registered',
        'slug': slug,
        'referrer': referrer or '',
        'user_agent': user_agent or '',
        'timestamp': datetime.utcnow().isoformat()
    }
    producer.send('click_events', value=event)

@app.route('/<slug>')
def handle_redirect(slug):
    # 1. Try cache
    long_url = cache.get(slug)
    if long_url:
        publish_click_event(slug, request.referrer, request.user_agent.string)
        return redirect(long_url, code=301)

    # 2. Fallback to DB
    conn = get_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT long_url FROM mappings WHERE slug = %s", (slug,))
            row = cur.fetchone()
        if row:
            long_url = row[0]
            # Populate cache for subsequent requests
            cache.setex(slug, 3600, long_url)  # TTL 1 hour
            publish_click_event(slug, request.referrer, request.user_agent.string)
            return redirect(long_url, code=301)
        else:
            abort(404)
    finally:
        conn.close()

if __name__ == '__main__':
    app.run(port=5002)

Notice the cache-aside pattern: the service manages cache population after a DB hit. For extreme scale, consider a write-through cache updated by the shortening service whenever a new mapping is created.

Analytics Service

The analytics service consumes click events from Kafka, processes them (e.g., counting clicks per slug, time-series aggregation), and stores results in a dedicated analytics database like ClickHouse or a time-series store. Here's a simplified consumer that writes raw events to a PostgreSQL table for later querying.


# analytics_service/consumer.py
from kafka import KafkaConsumer
import json
import psycopg2
import os

DB_CONFIG = {
    'host': os.getenv('DB_HOST', 'localhost'),
    'database': os.getenv('ANALYTICS_DB', 'analytics'),
    'user': os.getenv('DB_USER', 'user'),
    'password': os.getenv('DB_PASSWORD', 'pass')
}

KAFKA_BOOTSTRAP = os.getenv('KAFKA_BOOTSTRAP', 'localhost:9092')
consumer = KafkaConsumer(
    'click_events',
    bootstrap_servers=KAFKA_BOOTSTRAP,
    value_deserializer=lambda m: json.loads(m.decode('utf-8')),
    group_id='analytics-group',
    auto_offset_reset='latest'
)

def get_db_connection():
    return psycopg2.connect(**DB_CONFIG)

def store_event(event):
    conn = get_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """INSERT INTO raw_clicks (slug, referrer, user_agent, clicked_at)
                   VALUES (%s, %s, %s, %s)""",
                (event['slug'], event['referrer'],
                 event['user_agent'], event['timestamp'])
            )
        conn.commit()
    except Exception as e:
        print(f"Error storing event: {e}")
    finally:
        conn.close()

print("Starting analytics consumer...")
for message in consumer:
    event = message.value
    print(f"Processing click for slug {event['slug']}")
    store_event(event)

For production, replace raw inserts with a streaming aggregation framework (e.g., Apache Flink, Spark Streaming) or a materialized view layer that precomputes top links, geographic distribution, etc.

API Gateway Configuration (Example with Nginx)

A lightweight API gateway can route traffic, enforce rate limits, and protect internal services. Here's a simple Nginx configuration snippet:


# nginx.conf snippet
server {
    listen 80;
    server_name short.ly;

    # Rate limiting zone
    limit_req_zone $binary_remote_addr zone=shorten_limit:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=redirect_limit:10m rate=100r/s;

    location /api/shorten {
        limit_req zone=shorten_limit burst=20 nodelay;
        proxy_pass http://shortening_service:5001;
    }

    location / {
        # All other paths go to redirect service
        limit_req zone=redirect_limit burst=50 nodelay;
        proxy_pass http://redirect_service:5002;
    }
}

How to Use the System

Once all services are deployed (via Docker Compose, Kubernetes, or a cloud orchestrator), interacting with the shortener is straightforward:

Shortening a URL

Send a POST request to the gateway:


curl -X POST https://short.ly/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"long_url": "https://example.com/very-long-page"}'

Response:


{"short_url": "https://short.ly/xYz9aB2", "slug": "xYz9aB2"}

Using the Short Link

Visiting https://short.ly/xYz9aB2 triggers a 301 redirect to the original URL. Browsers and HTTP clients follow it automatically.

Checking Analytics

The analytics service exposes a REST API (not shown in detail) to query aggregated data. For example:


GET /api/analytics/xYz9aB2?from=2025-01-01&to=2025-01-31

Returns JSON with click counts, referrer breakdowns, and geographic info.

Best Practices for Production

Conclusion

Designing a URL shortener with microservices turns a seemingly trivial application into a robust, scalable system ready for internet-scale traffic. By separating concerns — shortening, redirection, and analytics — into independent services, you gain the flexibility to scale components individually, choose the right tool for each job, and isolate failures. The practical code examples above demonstrate how to implement each piece with simple, proven patterns: REST APIs for synchronous operations, Redis for caching, PostgreSQL for durable storage, and Kafka for asynchronous event streaming. Adopting best practices around idempotency, caching, rate limiting, and observability ensures your shortener remains fast, reliable, and easy to operate. Whether you're building an internal tool or the next public link service, this microservices blueprint provides a solid foundation that you can extend with custom aliases, user accounts, or advanced analytics.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles