A URL shortener built in Go with Postgres, Redis, and rate limiting. You paste a long URL, get a short one back, and when someone visits it they get redirected. That's the core — but there's a decent amount going on under the hood to make it reliable and fast.
Live: deployed on Render with Neon (Postgres) and Upstash (Redis).
- Shorten any URL to a 7-character code
- Custom aliases (e.g.
/my-linkinstead of/aB3kX9z) - Optional expiry — links can be set to expire after N hours
- Click tracking — every redirect increments a counter
- Stats endpoint — see how many times a link was clicked
- Delete a link
- Go — standard library HTTP + chi router
- Postgres (Neon) — source of truth for all URLs
- Redis (Upstash) — caching layer + rate limiter state
- Render — hosts the Go binary
The code is split into three layers that only talk to the layer directly below them:
HTTP Request
↓
Handler → parses request, writes response, maps errors to status codes
↓
Service → business logic (code generation, alias validation, click tracking)
↓
Store → data access (Postgres + Redis cache)
The Store interface is what makes this clean — the service doesn't know or care whether data is coming from Redis or Postgres. The CachedStore wraps PostgresStore and handles that transparently.
When you shorten a URL without a custom alias, the service generates a random 7-character code using base62 encoding:
characters: 0-9 a-z A-Z → 62 possible characters
7 characters → 62^7 = ~3.5 trillion possible codes
The randomness comes from crypto/rand (not math/rand) — it uses the OS's cryptographically secure random source so codes are unpredictable and can't be guessed sequentially.
If a generated code collides with one that already exists in the database, it retries up to 3 times. At the scale this app runs, the probability of even one collision is extremely low.
Redirects are the most frequent operation by far, so hitting Postgres on every single redirect would be slow and wasteful. Redis sits in front of Postgres as a cache.
The pattern used is cache-aside (also called lazy loading):
- On write (shorten): only write to Postgres. Nothing goes to Redis yet.
- On read (redirect): check Redis first. If the code is there (cache hit), return it immediately — Postgres is never touched. If it's not there (cache miss), fetch from Postgres, store the result in Redis with a 24-hour TTL, then return it.
This means only URLs that are actually clicked ever occupy Redis memory. A URL that gets created but never visited never wastes cache space.
TTL and expiry: if a URL has an expiry time set, the cache TTL is set to whichever is shorter — 24 hours or the time until expiry. This ensures the cache never serves a URL after it's expired.
Graceful degradation: if Redis goes down, the cache layer logs a warning and falls through to Postgres. The app keeps working, just slower.
On delete: we delete from Postgres first, then evict the key from Redis. If the Redis eviction fails, we log it but don't fail the request — a stale cache entry is a temporary inconsistency, not a correctness problem, since the TTL will eventually clean it up.
Rate limiting is applied only to POST /shorten — not to redirects, because redirects are the whole point of the service and should never be blocked.
The algorithm is a fixed window counter:
- Each IP gets a key in Redis:
ratelimit:<ip> - On every request, the counter is incremented with
INCR - On the first request (count == 1), a 60-second expiry is set on the key
- If the counter exceeds the limit (10 requests), the request is rejected with
429 Too Many Requests - After 60 seconds the key expires and the window resets
The window is fixed — it starts from the IP's first request, not from a rolling clock. So if you make your first request at :30, your window runs until :90.
Why Redis for this? If you stored rate limit counters in-memory inside the Go process, each server instance would have its own separate counter. An IP could hit 10 different instances and send 100 requests without ever being blocked. Redis is shared across all instances, so the counter is global and consistent.
Fail-open: if Redis is unreachable, the rate limiter logs the error and lets the request through rather than blocking it. The reasoning is that a brief window of unenforced limits is less harmful than making the shorten endpoint completely unavailable during a Redis outage.
IP extraction: the middleware checks X-Forwarded-For first (set by Render's proxy), then X-Real-IP, then falls back to RemoteAddr. This ensures the real client IP is used rather than the proxy's IP.
When shortening, you can pass expires_in_hours in the request body. The expiry time is stored in Postgres.
Expiry is checked in Go, not SQL. The query fetches the row and then Go checks if time.Now().After(expiresAt). This lets us return a distinct ErrExpired error which maps to 410 Gone — different from 404 Not Found. A 410 tells clients and crawlers that this resource existed but is permanently gone, which is more semantically correct than a 404.
Every time someone is redirected, the click count for that code is incremented in Postgres. But we don't want click tracking to slow down the redirect — the user should be sent to their destination immediately.
So the increment runs in a fire-and-forget goroutine:
go func() {
s.store.IncrementClicks(context.Background(), code)
}()
return original, nil // redirect happens immediatelyThe redirect returns before the DB write even starts. If the increment fails, it's logged but the user is unaffected.
Known limitation: at very high traffic (e.g. 10,000 clicks/second on one viral link), every goroutine races to UPDATE the same Postgres row. The DB serialises those writes and throughput degrades — this is the "hot row" problem. Two ways to fix it if it ever becomes real:
- Batching via channel — send codes into a buffered channel; a background goroutine drains it and writes
clicks = clicks + Nonce per second. - Redis INCR — increment an in-memory counter in Redis on each click (cheap), then a background job syncs totals to Postgres periodically.
You can provide your own alias instead of getting a random code. A few rules:
- Aliases must be unique — trying to claim one that's taken returns
409 Conflict - Certain words are reserved and can't be used as aliases:
api,admin,login,static,health— these would conflict with actual routes (if created in the future :P)
All errors are returned as JSON with a consistent shape:
{ "error": "short URL not found" }Errors are mapped to HTTP status codes at the handler layer:
| Error | Status |
|---|---|
| URL not found | 404 Not Found |
| URL expired | 410 Gone |
| Alias taken | 409 Conflict |
| Reserved alias | 400 Bad Request |
| Rate limit exceeded | 429 Too Many Requests |
| Anything else | 500 Internal Server Error |
Internal errors (DB failures, etc.) are logged with structured JSON via slog but the response body never leaks internal details to the client.
GET /health returns the status of both Postgres and Redis:
{ "status": "ok", "db": "ok", "cache": "ok" }If either dependency is down, the relevant field shows "error" and the response is 500. Render uses this to detect unhealthy deploys.
| Service | Provider |
|---|---|
| Go backend | Render |
| Postgres | Neon |
| Redis | Upstash |
Build command: go build -o server ./cmd/server
Start command: ./server
Environment variables needed:
| Variable | Description |
|---|---|
DATABASE_URL |
Neon connection string (includes sslmode=require) |
REDIS_URL |
Upstash Redis URL (rediss:// with TLS) |
PORT |
Set automatically by Render |
The migrations in migrations/ need to be run once manually against Neon (via Neon's SQL editor) before the first deploy.