<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: dalin zhou (钱先生)</title>
    <description>The latest articles on DEV Community by dalin zhou (钱先生) (@dalin_zhou_e8f17ca6).</description>
    <link>https://dev.to/dalin_zhou_e8f17ca6</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4173430%2Fc840c530-346c-44c1-a81a-90b63786316a.png</url>
      <title>DEV Community: dalin zhou (钱先生)</title>
      <link>https://dev.to/dalin_zhou_e8f17ca6</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dalin_zhou_e8f17ca6"/>
    <language>en</language>
    <item>
      <title>How I Built a URL Shortener with Click Analytics (Architecture Deep Dive)</title>
      <dc:creator>dalin zhou (钱先生)</dc:creator>
      <pubDate>Fri, 09 Oct 2026 12:35:47 +0000</pubDate>
      <link>https://dev.to/dalin_zhou_e8f17ca6/how-i-built-a-url-shortener-with-click-analytics-architecture-deep-dive-4ini</link>
      <guid>https://dev.to/dalin_zhou_e8f17ca6/how-i-built-a-url-shortener-with-click-analytics-architecture-deep-dive-4ini</guid>
      <description>&lt;p&gt;Most URL shorteners solve the "make it short" problem and stop there. When I set out&lt;br&gt;
to build PicKu, I wanted to answer a different question: what actually happens&lt;br&gt;
&lt;em&gt;after&lt;/em&gt; someone clicks your short link?&lt;/p&gt;

&lt;p&gt;This is a write-up of the architecture decisions behind PicKu — including the ones&lt;br&gt;
that turned out to be harder than expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious requirements
&lt;/h2&gt;

&lt;p&gt;A URL shortener needs three things to work at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fast alias lookup&lt;/strong&gt; — resolve &lt;code&gt;picku.org/aBcD&lt;/code&gt; to a destination URL in single-digit milliseconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe redirects&lt;/strong&gt; — open redirect is the #1 abuse vector for any shortener&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Click counting&lt;/strong&gt; — the reason most people actually use one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the easy part. The interesting parts were everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why Redis for alias resolution
&lt;/h2&gt;

&lt;p&gt;The lookup path is read-heavy and latency-sensitive: every click is a read, and&lt;br&gt;
short links are hit in bursts. A link gets shared, a Slack channel reacts, traffic&lt;br&gt;
spikes for ten minutes, then it dies.&lt;/p&gt;

&lt;p&gt;A relational database handles this fine — until it doesn't. Sibling rows land on the&lt;br&gt;
same physical page, and a viral link turns into a hot-page contention problem.&lt;/p&gt;

&lt;p&gt;Redis key-value lookups are O(1) with no page contention, which is exactly the shape&lt;br&gt;
of this workload. The tradeoff is durability: Redis alone loses data on restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern I settled on&lt;/strong&gt;: Redis as the read path, with write-through to the&lt;br&gt;
primary store on every create. Reads are fast and cheap; the source of truth is still&lt;br&gt;
durable. If Redis comes back empty after a restart, it rebuilds from the durable store.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Link expiry without a cron army
&lt;/h2&gt;

&lt;p&gt;Expiring links sounds like it needs a scheduled job scanning every link every minute.&lt;br&gt;
That approach falls over quickly — a million links means a million-row scan every&lt;br&gt;
minute, and most of those links are nowhere near expiry.&lt;/p&gt;

&lt;p&gt;Instead: &lt;strong&gt;lazy expiry&lt;/strong&gt;. Store &lt;code&gt;expires_at&lt;/code&gt; with each link. On every lookup, check it:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
python
if link.expires_at and link.expires_at &amp;lt; now:
    mark_expired(link)
    return 410, "Gone"
return redirect(link.destination)
Zero background cost. Expired links cost one timestamp comparison, and you only pay
it on links actually being clicked — which, for most expired links, is never.
If you're building this yourself: decide your abuse policy before you write the
redirect handler. Retrofitting it means rewriting the hot path.
3. The abuse problem is the real hard part
Any URL shortener becomes an open redirect service the moment someone posts a
malicious link to a forum. Two things handle it:
Domain allowlisting for custom aliases. Custom aliases are opt-in, and the set of
destinations that may use them is controlled. This is the single biggest abuse
reduction lever.
Reputation scoring, not keyword blocking. Every destination domain accumulates a
score based on reports, age, and traffic patterns. Below a threshold, links are held
for review instead of served immediately.
The reason not to go keyword-filter-first: attackers trivially evade keyword filters,
and legitimate short links get caught constantly. Score the destination, not the string.
4. Analytics: what people actually want to see
The raw click count is the least interesting number. What people pay for is where the
click came from:
- Referrer — the referring URL, not just "direct"
- Country — resolved at click time, not at redirect time
- Device / browser — parsed from the user agent
- Timestamp — with a sane retention window, not forever
Two design notes that mattered:
Resolve geo at click time, not at creation time. A link created in one country
often gets clicked in another. Resolving at redirect is both cheaper and correct.
Retention beats resolution here. Analytics that keep three years of per-click rows
are expensive and nobody reads them that far back. A tight window (90 days on the free
tier, longer for paid) is the better trade.
5. Custom splash and overlay pages
Sometimes you don't want an instant redirect — you want a page with your branding, a
countdown, or a click-through. This is what the Direct Frame vs Splash options in the
UI control:
- Direct frame → straight redirect, zero friction
- Splash → branded intermediate page, optional countdown
- Custom domain → your own domain stays in the address bar, which matters for
brand trust and for link-preview screenshots in social apps
Custom domains are also what make a short link survive being shared in a room full of
people who don't trust bit.ly.
What I'd do differently
If I rebuilt this, I'd spend two more weeks on rate limiting and bot detection
before launch rather than after. Every shortener attracts scrapers within days of
launching — they're scanning for open redirects. Building it in from the start is
cheaper than retrofitting it under load.
The other thing I'd do earlier: make the API first-class from day one. A lot of
the long-term value came from integrations, not from the web UI.
If you're interested in comparing implementations or have questions about any of the
above, I'm happy to go deeper in the comments.
For reference, the tool this post is based on is here: https://picku.org — free tier
covers 10 links, and the paid tier removes ads and unlocks unlimited links, custom
domains, and API access.
Questions about any part of this? Happy to go deeper.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>webdev</category>
      <category>redis</category>
      <category>architecture</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
