Exercise - Link Shortener
Build your own link shortener, like bit.ly or tinyurl, and deploy it to Azure.
Visitors open a short link such as https://<your-app>.azurewebsites.net/k7Fq2a and get redirected to the original URL immediately. In the background, every click is counted. The link owner can view statistics: how often a link was clicked, on which days, and which links are the most popular.
The catch: statistics are not calculated when someone asks for them. They are pre-computed by a separate background process, the Aggregator. As a result, the numbers you see are always a little behind reality. That’s intentional, and it’s the core of this exercise.
Learning Goals
After this exercise you should be able to:
- explain the difference between a hot path (must be fast) and a cold path (may be slow and deferred)
- decouple two applications with a message queue
- explain why some values have to be pre-aggregated instead of queried on demand
- explain eventual consistency with an example you built yourself
- design tables for Azure Table Storage around the queries you need
- deploy a C# Minimal API and a background job to Azure App Service
Why Do We Need an Aggregator?
Imagine your shortener gets popular: 50,000 clicks per day.
Option A: compute stats on demand. Every time someone opens the stats page, read all click events and count them. That means reading 50,000+ entities for a single page view. It gets slower every day, and many users looking at stats at the same time will slow down the redirects too.
Option B: compute stats ahead of time. A redirect only drops a tiny message into a queue and returns immediately. A separate process picks up the messages, updates the counters, and writes the results to a stats table. Opening the stats page becomes a single, cheap lookup.
There is a second, very concrete reason: Azure Table Storage can’t aggregate. There is no COUNT, no GROUP BY, no ORDER BY, no SUM. You couldn’t write “give me the top 10 links” as a query even if you wanted to. Somebody has to compute the result and store it, and that somebody is the Aggregator.
The price you pay: the stats are eventually consistent. Click your link five times, open the stats page, and you might see 3. A few seconds later it says 5. You’ve seen this before: YouTube view counters that lag behind, likes that appear with a delay, or the Spotify play count of a new song.
flowchart LR subgraph HOT["🔥 Hot path: must be fast"] direction LR C[Click] --> R[Look up link] --> Q[Enqueue click event] --> RD[302 Redirect] end subgraph COLD["❄️ Cold path: may lag behind"] direction LR D[Dequeue events] --> S[Store raw clicks] --> A[Update counters and top list] --> W[Write stats tables] end Q -. "Azure Queue Storage" .-> D
Architecture
flowchart TB User(["👤 Visitor (browser)"]) Owner(["🧑💻 Link owner"]) subgraph AppService["Azure App Service (Linux)"] API["ShortLink API<br/>C# Minimal API"] AGG["Aggregator<br/>C# Console App<br/>(deployed as WebJob)"] end subgraph Storage["Azure Storage Account"] QUEUE[["Queue: clicks"]] LINKS[("Table: Links")] CLICKS[("Table: Clicks")] STATS[("Table: LinkStats")] TOP[("Table: TopLinks")] end User -- "GET /{code}" --> API Owner -- "POST /api/links<br/>GET /api/links/{code}/stats" --> API API -- "point query" --> LINKS API -- "enqueue click event" --> QUEUE API -- "read pre-computed stats" --> STATS API -- "read pre-computed stats" --> TOP QUEUE -- "dequeue" --> AGG AGG -- "store raw click" --> CLICKS AGG -- "update counters" --> STATS AGG -- "rewrite top list" --> TOP
The API never counts anything. It only reads values that the Aggregator has already computed.
What Happens When Someone Clicks a Link
sequenceDiagram autonumber actor V as Visitor participant API as ShortLink API participant L as Table: Links participant Q as Queue: clicks participant AGG as Aggregator participant S as Table: LinkStats V->>API: GET /k7Fq2a API->>L: Get entity (PK, RK) L-->>API: https://www.htlkrems.ac.at/... API->>Q: SendMessage { code, timestamp, userAgent, referrer } API-->>V: 302 Found, Location: https://www.htlkrems.ac.at/... Note over V,API: Visitor is redirected.<br/>Nothing has been counted yet! loop every few seconds AGG->>Q: ReceiveMessages (up to 32) Q-->>AGG: click events AGG->>S: update counters AGG->>Q: DeleteMessage end
Eventual Consistency, Visualized
sequenceDiagram actor O as You participant API as ShortLink API participant Q as Queue participant AGG as Aggregator O->>API: click /k7Fq2a (5×) API->>Q: 5 messages O->>API: GET /api/links/k7Fq2a/stats API-->>O: total: 0 😕 AGG->>Q: receive 3 messages AGG->>AGG: total = 3 O->>API: GET /api/links/k7Fq2a/stats API-->>O: total: 3 🤔 AGG->>Q: receive 2 messages AGG->>AGG: total = 5 O->>API: GET /api/links/k7Fq2a/stats API-->>O: total: 5 ✅ Note over O,AGG: The system was never "wrong".<br/>It was just not yet consistent.
Requirements
REST API (C# Minimal API)
| Method | Route | Description |
|---|---|---|
POST | /api/links | Create a short link. Body contains the target URL (and optionally a custom code). Returns the generated code and the full short URL. |
GET | /api/links | List all links. |
GET | /api/links/{code} | Get details of a single link. |
PUT | /api/links/{code} | Change the target URL of a link. |
DELETE | /api/links/{code} | Delete a link. |
GET | /{code} | Redirect to the target URL and enqueue a click event. Returns 404 for unknown codes. |
GET | /api/links/{code}/stats | Total clicks and clicks per day for one link (read from LinkStats). |
GET | /api/stats/top | The 10 most clicked links (read from TopLinks). |
The redirect endpoint must not wait for anything related to statistics. Look up the link, enqueue the event, redirect. Done.
classDiagram class Link { +string Code +string TargetUrl +DateTimeOffset CreatedAt } class ClickEvent { +Guid ClickId +string Code +DateTimeOffset Timestamp +string? UserAgent +string? Referrer } class LinkStats { +string Code +int TotalClicks +Dictionary~DateOnly,int~ ClicksPerDay } class TopLink { +int Rank +string Code +string TargetUrl +int TotalClicks } ClickEvent --> Link : refers to LinkStats --> Link : aggregated for TopLink --> Link : ranks
ClickEvent is the message you put in the queue, serialized as JSON.
Aggregator (C# Console App)
The Aggregator runs continuously:
- Receive a batch of messages from the
clicksqueue. - Store each click as a raw entity in the
Clickstable. - Update the counters in
LinkStats(total and per day). - Delete the processed messages from the queue.
- Every 60 seconds, recompute the top 10 list and rewrite the
TopLinkstable. - If the queue is empty, wait a few seconds before polling again. Don’t hammer the queue in a tight loop.
Log what you’re doing to the console (for example Processed 12 clicks, 3 links updated). On Azure, these logs show up in the WebJob log.
Dashboard (optional, but recommended)
A single static HTML page served by the API (wwwroot/index.html) that lets you create links and shows the stats and the top list, refreshing every few seconds. This is the easiest way to watch eventual consistency happen.
Azure Table Storage: What It Can and Can’t Do
Table Storage is a key-value store with a little bit of extra structure. Every entity has two keys:
- PartitionKey: groups entities together. Entities in the same partition are stored together.
- RowKey: unique within a partition.
PartitionKey + RowKey is the primary key. Everything else is an ordinary property.
flowchart LR subgraph T["Table: LinkStats"] direction TB subgraph P1["Partition: k7Fq2a"] E1["RowKey: total, Count: 128"] E2["RowKey: 2026-10-01, Count: 40"] E3["RowKey: 2026-10-02, Count: 88"] end subgraph P2["Partition: Xy91bQ"] E4["RowKey: total, Count: 7"] E5["RowKey: 2026-10-02, Count: 7"] end end
✅ What Table Storage Is Good At
| Operation | Speed | Example |
|---|---|---|
| Point query (PartitionKey + RowKey) | ⚡ fastest | Get the link with code k7Fq2a |
| Partition scan (all entities of one PartitionKey) | 🚀 fast | All stats rows for link k7Fq2a |
Range query within a partition (RowKey ge/lt) | 🚀 fast | Clicks per day for October |
| Insert, upsert, replace, delete of single entities | ⚡ fast | Add a click, update a counter |
| Batch transaction | 🚀 fast | Up to 100 operations, same partition only |
| Optimistic concurrency with ETags | — | Only update the counter if nobody else changed it in the meantime |
❌ What Table Storage Can’t Do
- No aggregation: no
COUNT,SUM,AVG,GROUP BY - No custom sorting: no
ORDER BY. Results are always sorted by PartitionKey, then RowKey, as strings. - No joins, no foreign keys
- No secondary indexes: filtering on a normal property (
TargetUrl eq '...') works but scans the whole table - No transactions across partitions
- Results come in pages of at most 1,000 entities. The SDK pages for you, but a full table scan is still slow.
Design Rules That Follow From This
- Design tables around your queries, not around your entities. Ask “what will I look up, and by which key?” first.
- Duplicate data if it saves you a query. Storing
TargetUrlagain inTopLinksis perfectly fine. - Keys are strings and sort lexicographically.
"10"sorts before"9". Pad numbers:"01","02", …,"10". Dates asyyyy-MM-ddsort correctly. - Newest first? Use “inverted ticks” as the RowKey:
(DateTime.MaxValue.Ticks - timestamp.Ticks).ToString("D19").
Suggested Table Design
| Table | PartitionKey | RowKey | Properties | Written by |
|---|---|---|---|---|
Links | code | "link" | TargetUrl, CreatedAt | API |
Clicks | code | inverted ticks + _ + ClickId | Timestamp, UserAgent, Referrer | Aggregator |
LinkStats | code | "total" or yyyy-MM-dd | Count | Aggregator |
TopLinks | "top" | rank ("01" … "10") | Code, TargetUrl, TotalClicks | Aggregator |
💡 Why is the PartitionKey of
Linksthe code and not something like"links"? With one big partition, all requests hit the same partition, which limits throughput. Using the code spreads the load. Discuss: what is the downside when you want to list all links?
Azure Queue Storage: What You Need to Know
A queue holds messages until somebody processes and deletes them. If the Aggregator is down, nothing is lost: messages simply wait in the queue, and the redirects keep working.
stateDiagram-v2 [*] --> Visible: SendMessage (API) Visible --> Invisible: ReceiveMessages (Aggregator) Invisible --> Deleted: DeleteMessage (processing succeeded) Invisible --> Visible: visibility timeout expires (Aggregator crashed or was too slow) Visible --> Expired: time-to-live exceeded (default 7 days) Deleted --> [*] Expired --> [*]
Important properties:
- Receiving does not remove a message. It only hides it for the visibility timeout (default 30 seconds). You must call
DeleteMessageafter successful processing. - At-least-once delivery. If your Aggregator crashes after processing but before deleting, the message comes back. Your code must handle a message being processed twice.
- No guaranteed order. Don’t rely on messages arriving in the order they were sent.
- A message can be at most 64 KB. A click event is a few hundred bytes. Fine.
ReceiveMessagesreturns at most 32 messages per call.- Every message has a
DequeueCount. If a message keeps failing, move it to aclicks-poisonqueue instead of retrying forever. - Use the same
QueueClientOptions.MessageEncodingin the API and the Aggregator (bothNoneor bothBase64), or they won’t understand each other.
Hints for the Implementation
NuGet Packages
Azure.Data.Tables:TableServiceClient,TableClient,ITableEntityAzure.Storage.Queues:QueueClient
Register the clients as singletons in the API and call CreateIfNotExistsAsync() for every table and the queue on startup.
Generating Short Codes
Generate a random code of 6–7 characters from [a-zA-Z0-9]. Use AddEntityAsync to insert the link. It fails with HTTP 409 Conflict if the code already exists, so you can simply try again with a new code.
302 vs. 301
Results.Redirect(url) returns 302 Found. Think about why you should not use 301 Moved Permanently here. (Hint: what does the browser do with a permanent redirect the second time you click?)
Counting Correctly
- Aggregate a batch in memory first (e.g. 25 messages → 3 links with +12, +8, +5) and write each counter once. That’s much cheaper than 25 separate updates.
- Because of at-least-once delivery, a click can arrive twice. Since the RowKey of a raw click contains the
ClickId, a duplicate insert intoClicksfails with409 Conflict. Use that to detect duplicates and skip counting them. - If more than one Aggregator could run at the same time, use the entity’s ETag when updating counters, and retry on
412 Precondition Failed.
Local Development
You can connect to your Storage Account on Azure for development and testing.
Optional alternative: Use Azurite, the local Azure Storage emulator. Install it via npm or Docker.
Alternatively, use the Azurite extension for VS Code. Use UseDevelopmentStorage=true as the connection string. Azure Storage Explorer lets you look into your tables and queues, locally and in Azure.
Read the connection string from configuration (Storage:ConnectionString), never hard-code it. On Azure, the App Service app setting Storage__ConnectionString maps to exactly that key.
Setting Up Azure
Everything runs in one resource group: one Storage Account, one App Service Plan, one Web App. The Aggregator runs as a WebJob inside the same Web App, so it costs nothing extra.
flowchart TB subgraph RG["Resource Group: rg-shortlink-<initials>"] SA["Storage Account<br/>stshortlink<initials><br/>Standard LRS"] subgraph PLAN["App Service Plan (Linux, B1)"] WEB["Web App<br/>app-shortlink-<initials><br/>.NET 10"] JOB["Continuous WebJob<br/>aggregator"] end end WEB --- JOB WEB -- "Storage__ConnectionString" --> SA JOB -- "same app settings" --> SA
Resources
| Resource | Setting | Why |
|---|---|---|
| Storage Account | Standard, LRS | Holds tables and the queue. One account is enough. Name must be globally unique, lowercase, no dashes. |
| App Service Plan | Linux, B1 or higher | The Free tier (F1) doesn’t support Always On, which a continuous WebJob needs. Otherwise the app goes to sleep and the Aggregator stops. |
| Web App | Runtime .NET 10 (Linux) | Hosts the API. The WebJob uses the same runtime, so it can run your .NET console app. |
Deploying the API
dotnet publish -c Release -o publish
cd publish && zip -r ../api.zip . && cd ..
az webapp deploy -n app-shortlink-<initials> -g rg-shortlink-<initials> \
--src-path api.zip --type zipDeploying via the Azure extension in VS Code or Rider works too.
Deploying the Aggregator as a WebJob
WebJobs are a feature of App Service that runs background programs next to your web app. They have been generally available on Linux since 2025. There are two kinds:
- Continuous: starts automatically and runs forever (restarted if it crashes). ✅ Use this one.
- Triggered: runs on demand or on a CRON schedule.
flowchart LR A["dotnet publish<br/>Aggregator"] --> B["add run.sh<br/>+ settings.job"] --> C["zip everything"] --> D["Portal: Web App → WebJobs → Add<br/>Type: Continuous"] --> E["Check logs<br/>in the WebJob dashboard"]
-
Publish the console app:
dotnet publish -c Release -o publish -
Add a
run.shto the publish folder. On Linux, this is the entry point of the WebJob:#!/bin/bashdotnet Aggregator.dll -
Add a
settings.jobso that only one instance runs, even if the App Service scales out to several instances:{ "is_singleton": true } -
Zip the contents of the publish folder (not the folder itself).
-
Uploading via the portal requires SCM Basic Auth Publishing Credentials to be enabled (Web App → Configuration → General settings), or via CLI:
az resource update -g rg-shortlink-<initials> --name scm \ --namespace Microsoft.Web --resource-type basicPublishingCredentialsPolicies \ --parent sites/app-shortlink-<initials> --set properties.allow=true -
In the portal: Web App → WebJobs → Add → upload the zip, type Continuous.
The WebJob sees the same app settings (environment variables) as the Web App, so Storage__ConnectionString is available without extra configuration.
💡 In the real world, new background workloads are often built with Azure Functions or Container Apps Jobs. We use a plain console app + WebJob on purpose: there’s no magic, and you can see exactly what your code does.
Tasks
- Local setup: Create a solution with two projects:
ShortLink.ApiandShortLink.Aggregator, plus (optionally) a shared class library for theClickEventmodel. - Links: Implement creating, listing, updating and deleting links in the
Linkstable. - Redirect: Implement
GET /{code}with the redirect and enqueue the click event. Verify in Storage Explorer that messages arrive in the queue. - Aggregator: Process the queue, store raw clicks, update
LinkStats, delete messages. Then add the periodicTopLinkscomputation. - Stats endpoints: Implement
/api/links/{code}/statsand/api/stats/top, reading only from the pre-computed tables. - Deploy: Create the Azure resources, deploy the API, deploy the Aggregator as a continuous WebJob.
- Observe: Click your links in Azure, watch the stats catch up. Stop the WebJob, click a few more times, check the queue length, start the WebJob again and watch the backlog disappear.
Reflection Questions
Answer these briefly in your submission:
- Why does the redirect endpoint not update the click counter directly?
- You click a link 5 times and the stats say 3. Is this a bug? Explain.
- What happens to clicks while the Aggregator is stopped? What happens to the redirects?
- What happens if the Aggregator crashes after updating the counter but before deleting the message? How did you handle this?
- Why can’t you simply query “top 10 links” from Table Storage?
- Why is
301a bad choice for the redirect in this application? - Name two real-world systems that show eventually consistent numbers.
Bonus Challenges
- Load generator: A small console app that fires thousands of clicks. How long does the Aggregator take to catch up? How does the in-memory batching help?
- Browser and referrer stats: Count clicks per browser (parse the User-Agent roughly) and per referrer.
- Unique visitors: Count unique visitors per day without storing personal data (hint: hash of IP + day).
- Poison queue: Move messages with a
DequeueCount > 5toclicks-poison. - Managed Identity: Remove the connection string. Enable a system-assigned managed identity for the Web App, assign the roles Storage Table Data Contributor and Storage Queue Data Contributor, and use
DefaultAzureCredential. - Expiring links: Links with an expiry date return
410 Goneafterwards. - QR codes:
GET /api/links/{code}/qrreturns a QR code image for the short link.