Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

URL Shortener

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).


What it does

  • Shorten any URL to a 7-character code
  • Custom aliases (e.g. /my-link instead 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

Stack

  • 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

Architecture

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.


Code Generation

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.


Caching (Cache-Aside Pattern)

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

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.


URL Expiry

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.


Click Tracking

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 immediately

The 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:

  1. Batching via channel — send codes into a buffered channel; a background goroutine drains it and writes clicks = clicks + N once per second.
  2. Redis INCR — increment an in-memory counter in Redis on each click (cheap), then a background job syncs totals to Postgres periodically.

Custom Aliases

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)

Error Handling

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.


Health Check

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.


Deployment

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.

About

URL Shortener built in GO with Postgres, Redis and Rate Limiting

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages