Client → API Gateway → URL Write Service → KGS → PostgreSQL (primary) → Redis (warm)
POST /api/v1/urls with the long URLEndpoint:
POST /api/v1/urls
Headers: Authorization: Bearer <token>
X-Idempotency-Key: <uuid> # safe retries
Body:
{
"long_url": "https://example.com/some/very/long/path",
"custom_alias": "my-key", # optional
"ttl_days": 30 # optional, omit for permanent
}
201 Created:
{
"short_url": "https://short.ly/aB3dEfG",
"short_key": "aB3dEfG",
"expires_at": "2026-04-06T00:00:00Z" # null if permanent
}
409 Conflict → custom alias already taken
422 Unprocessable → long_url is malformed or blocklisted
429 Too Many Requests → rate limit exceeded
Other write endpoints:
DELETE /api/v1/urls/{short_key} # soft-delete, owner-only → 204
Client → CDN (cache hit → 302) → API Gateway → Redirect Service → Redis → PostgreSQL (replica)
GET /{short_key} (e.g., clicking a link)url.clicked event is published to Kafka asynchronously (non-blocking)Endpoint:
GET /{short_key}
302 Found
Location: https://original-long-url.com/...
404 Not Found → key does not exist or has expired
410 Gone → key was explicitly deleted
301 vs 302 decision: | | 301 Permanent | 302 Temporary | |-|--------------|---------------| | Browser caches redirect | Yes — no future server hit | No — every click hits server | | Click analytics possible | No | Yes | | CDN cache TTL | Long (days) | Short (minutes) | | Use when | No analytics needed, max caching | Analytics required (chosen) |
service KeyGenerationService {
rpc AcquireKey(AcquireKeyRequest) returns (AcquireKeyResponse);
}
message AcquireKeyRequest {}
message AcquireKeyResponse {
string key = 1; // 7-char Base62, guaranteed unique
}
Called only by URL Write Service. Not exposed externally.
- Create short URL
- TTL expiration
Handles all mutations: creating short URLs, updating TTL, deleting. Separated from the read path because write traffic (~500 QPS) has fundamentally different characteristics — it requires ACID guarantees, calls KGS for a key, and writes to the primary DB. Bundling this with redirect logic would force the redirect path (50k QPS, p99 < 10ms) to share resources with slower, heavier write operations.
The hottest path in the system. Every short link click hits this service. It is intentionally kept minimal — one job: look up short_key → long_url and return a 302. Separating it means we can scale it independently (10-100 pods), optimize it aggressively (Redis-first, no write DB connection pool), and ensure a Redis slowdown on the write side never bleeds into redirect latency.
Solves the uniqueness problem without putting the burden on the write hot path. If the Write Service generated keys itself (e.g., hash or counter), every write would need a uniqueness check against the DB — a round-trip that adds latency and creates contention at scale. KGS pre-generates keys in bulk and hands them out atomically via gRPC, so a write just pops a ready key in < 1ms with zero collision risk.
Click tracking is not on the critical path — a user doesn't wait for analytics before being redirected. Decoupling it via Kafka means: (1) a slow analytics query never blocks a redirect, (2) the analytics workload (aggregation, storage writes to Cassandra) can scale independently, and (3) if the analytics service goes down, redirects still work — we just buffer events in Kafka until it recovers.