DEV Community

Ango Jeffrey
Ango Jeffrey

Posted on

Building a URL Shortener: My First Real Dive into Backend Fundamentals

I recently built a URL shortener, and it turned out to be one of the best decisions I've made since I started learning backend development. It was actually recommended to me as a starter project, and I can see why. It's small enough to finish in a reasonable amount of time, but it touches almost every core backend concept: databases, caching, rate limiting, and HTTP semantics you don't really think about on the frontend.

Here's a walkthrough of what I built and what it taught me. You can find the full source code here: github.com/Ango-Jay/url-shortener.

What a URL shortener actually is

Before this project, a URL shortener was kind of a black box to me. I used them all the time but never thought about what was happening underneath. Turns out the concept is simple: you take a long URL, generate a short, unique code for it (usually somewhere around 8–11 characters), and store the pair together. When someone visits the short link, the service looks up the code and sends them to the original URL.

A good real-world example is LinkedIn's custom URLs, which are really just a human-friendly alias sitting in front of a much longer, uglier URL.

Generating the short code

For the code generation, I used nanoid to create a random alias and tried saving it. Since collisions are possible (however unlikely), I wrapped the save in a retry loop that regenerates a new code if the alias already exists in the database:

const MAX_LENGTH = 8;
const MAX_RETRY_ATTEMPT = 5;

async function createAlias(url: string): Promise<Alias> {
  if (!validateUrl(url)) {
    throw new BadRequestError("Invalid url", "INVALID_URL");
  }

  for (let attempt = 0; attempt < MAX_RETRY_ATTEMPT; attempt++) {
    const alias = nanoid(MAX_LENGTH);
    const created = repository.create({ alias, url });

    try {
      const saved = await repository.save(created);
      cache.set(saved.alias, saved);
      return saved;
    } catch (error) {
      if (isUniqueViolation(error)) {
        continue;
      }
      throw error;
    }
  }

  throw new ConflictError("Alias already exists", "ALIAS_ALREADY_EXISTS");
}
Enter fullscreen mode Exit fullscreen mode

Rather than checking the database upfront for a collision, I let the database's unique constraint do the work, then catch the violation and retry. This was also my first real, hands-on look at working with a relational database (Postgres in this case) instead of just reading about it.

The Location header: an "aha" moment

This was the most eye-opening part of the whole project for me. I've been on the frontend for a while, and this was the first time I'd properly encountered the Location header.

The way I think about it now: it's the API's way of shaking your hand and pointing you somewhere else. You call an endpoint (say, /abc123), and instead of the API sending back the destination content itself, it responds with a redirect status code and a Location header telling your browser "go here instead":

app.get("/:alias", async (req, res) => {
  const { alias } = req.params;
  const record = await aliasService.getAlias(alias);

  res.redirect(302, record.url);
  // under the hood this sets:
  // Status: 302 Found
  // Location: <record.url>
});
Enter fullscreen mode Exit fullscreen mode

The browser sees that Location header and automatically follows it: no extra JavaScript, no manual navigation logic. It's a small thing, but it reframed how I think about redirects and HTTP responses in general.

Rate limiting with Redis

I'd been hearing about Redis for a long time and wanted an excuse to actually use it, so I built it into the project as a rate limiter. The idea was simple: prevent a single alias from being hit hundreds or thousands of times in a short window, which could otherwise hammer the database or the destination service.

Redis is a great fit here because rate limiting needs fast, ephemeral counters, exactly what an in-memory store excels at. Every incoming request to getAlias increments a counter tied to that alias with a short expiry; once the count crosses a threshold within the window, further requests get rejected until it resets.

Setting up the client itself was straightforward. I wrapped the connection logic in a small helper so the rest of the app just imports a ready-to-use client:

import { createClient } from "redis";
import { config } from "./index";

export async function initializeRedis() {
  const client = createClient({ url: config.redisUrl });
  await client.connect();
  return client;
}

export type RedisClient = Awaited<ReturnType<typeof initializeRedis>>;
Enter fullscreen mode Exit fullscreen mode

Nothing fancy, just a single connection created once at startup and reused everywhere the rate limiter needs to read or write a counter.

The actual rate limiting lives in a Koa middleware that sits in front of the alias routes. It keys each counter by IP, increments it on every request, and sets an expiry the first time it sees that key so the window resets on its own:

import type { Context, Next } from "koa";
import { TooManyRequestsError } from "../common/error";
import type { RedisClient } from "../config/redis";

const LIMIT = 10;
const WINDOW_SECONDS = 60;

export function createRateLimitMiddleware(redis: RedisClient) {
  return async function rateLimit(ctx: Context, next: Next): Promise<void> {
    // TODO: if we sit behind a proxy, set app.proxy and trust X-Forwarded-For
    const key = `rl:aliases:${ctx.ip}`;
    const count = await redis.incr(key);

    if (count === 1) {
      await redis.expire(key, WINDOW_SECONDS);
    }

    const remaining = Math.max(0, LIMIT - count);
    ctx.set("RateLimit-Limit", String(LIMIT));
    ctx.set("RateLimit-Remaining", String(remaining));

    if (count > LIMIT) {
      const ttl = await redis.ttl(key);
      ctx.set("Retry-After", String(ttl > 0 ? ttl : WINDOW_SECONDS));
      throw new TooManyRequestsError();
    }

    await next();
  };
}
Enter fullscreen mode Exit fullscreen mode

A few things I liked about this approach once I had it working. INCR on a key that doesn't exist yet in Redis creates it at 1, so there's no separate "check if key exists" step. The expiry only gets set on the very first request in a window (count === 1), which keeps the TTL accurate instead of getting pushed back on every request. And exposing RateLimit-Limit, RateLimit-Remaining, and Retry-After as response headers means clients calling the API can see exactly how close they are to being throttled, instead of just getting a surprise 429.

Caching: choosing an LRU cache over Redis

I originally planned to use Redis for caching too, since it's the obvious choice. But I decided against it, partly because I wanted to actually learn how in-memory caches work under the hood, rather than just reaching for a tool that does it for me.

So I implemented the caching logic using an LRU (Least Recently Used) cache instead. I used the lru-cache package and wrapped it in a small factory function so the rest of the app doesn't need to know which caching library is underneath, just that it gets something matching a simple Cache interface:

import { LRUCache } from "lru-cache";

export function createCache<K extends {}, V extends {}>(
  options: LRUCache.Options<K, V, unknown>,
) {
  return new LRUCache<K, V>(options);
}

export type Cache<K extends {}, V extends {}> = ReturnType<
  typeof createCache<K, V>
>;
Enter fullscreen mode Exit fullscreen mode

That Cache<K, V> type is what gets passed into the alias service, so swapping the underlying implementation later wouldn't require touching the business logic at all. With the cache wired up, the actual lookup logic looks like this:

async function getAlias(alias: string): Promise<Alias> {
  const cached = cache.get(alias);
  if (cached) {
    return cached;
  }

  const result = await repository.findOneBy({ alias });

  if (!result) {
    throw new NotFoundError("Alias not found", "ALIAS_NOT_FOUND");
  }

  cache.set(alias, result);
  return result;
}
Enter fullscreen mode Exit fullscreen mode

The flow is: when a request comes in for a given alias, check the cache first. If it's there, return it immediately, no database round trip needed. If it's not, fall back to querying Postgres, then store the result in the cache for next time.

Because the cache has a fixed size, it can't hold everything forever. That's where the "LRU" part comes in: as the cache fills up, whichever key-value pair hasn't been accessed in the longest time gets evicted to make room for new entries. It's a neat way to keep frequently-requested aliases fast while not letting memory usage grow unbounded.

What I took away from this

Going in, this felt like "just a URL shortener," a common beginner project that gets recommended a lot. But it ended up being a solid crash course in:

  • Working with a relational database (Postgres) in a real application
  • Handling uniqueness constraints and retry logic gracefully
  • Understanding the Location header and how redirects actually work under the hood
  • Rate limiting with Redis
  • Building and reasoning about an in-memory LRU cache from scratch

Small project, but a surprising amount of depth once you actually dig into building it rather than just using it.

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

The collision-by-constraint choice is the right one and a lot of first attempts at this go the other way: a pre-flight SELECT then an INSERT looks tidy until two requests with the same alias land in the same millisecond and both see an empty table. Catching the unique violation and retrying is the only version that is actually correct under concurrency.

Two edges I'd be curious about as you keep building: what happens to the INCR window if Redis blips or restarts — do you fail open (request passes, no counter) or fail closed (429 until the store is back)? And your LRU is per process, so with more than one Node worker each one holds its own copy of a row that Postgres may have updated underneath it. For a shortener that's a stale redirect for a few seconds, which is tolerable — did you pick a TTL to bound it, or is the cache only ever invalidated by restart?