Build a URL Shortener API in Java
Build a concurrent REST API URL shortener using only built-in Java libraries.
Introduction
30 Second Summary
Every time you click a short link like bit.ly/xyz and land on the right page, a server is decoding that compact code, looking up the original URL, and redirecting you in milliseconds.
In this project, you will build a URL shortener REST API from scratch in pure Java with zero external dependencies. You will shorten URLs, redirect visitors, track hit counts, and handle concurrent traffic safely.
What You'll Build
You will run your shortener locally, POST a long URL, get back a compact code like q0U, and watch your browser redirect instantly when you visit it.
By the end of this project, you'll have:
- A working HTTP server that accepts a long URL and returns a short Base62-encoded code you can share with anyone.
- A redirect endpoint that resolves any short code to its original URL and sends the visitor there with a 302 response, plus a stats endpoint showing how many times each link has been clicked.
- Firsthand experience breaking your server with concurrency bugs, then fixing it with thread-safe primitives (AtomicLong and ConcurrentHashMap) so the server handles 10 simultaneous requests without data corruption.
- Secret Mission: Build an in-memory rate limiter that blocks abusive clients after too many requests.
Are there any prerequisites?
You should be comfortable with Java basics like classes, interfaces, lambdas, and streams. Familiarity with HTTP concepts (GET, POST, status codes) is also helpful.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters to you. This helps you stay focused as the project progresses.
Set Up Your Project
Every server you build in this project compiles and runs with a single javac and java command. For that to work, JDK 26 needs to be accessible from your terminal.
In this step, you'll confirm Java is ready, then create a project folder with the four source files that will become your URL shortener.
In this step, get ready to:
- Verify JDK 26 is accessible from your terminal.
- Create the project directory and four empty Java files.
- Open the project in VS Code.
Verify JDK 26 is on your PATH
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
Before you run the next command, what version number do you expect to see if everything is set up correctly?
- Check your Java runtime version by running this command:
java --version
You should see output starting with java 26. This confirms the runtime is installed and on your PATH.
- Now check the compiler version by running:
javac --version
You should see javac 26. Both the runtime and compiler are ready.
Not seeing version 26?
If you see command not found, JDK 26 is not on your system PATH. Close and reopen your terminal after installation, then try again.
If you see an older version number, you may have a previous JDK taking priority. Confirm the JDK 26 bin directory is first in your PATH.
Need help? Why isn't java --version showing version 26?
Create the project directory
Your shortener needs four source files. You'll create a dedicated folder on your Desktop so it's easy to find later.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir url-shortener
cd url-shortener
- Create the four empty Java source files by running:
macOS
touch UrlShortener.java UrlStore.java Base62.java JsonHelper.java
Windows
New-Item -ItemType File -Path "UrlShortener.java", "UrlStore.java", "Base62.java", "JsonHelper.java"
- Confirm the files exist by running:
ls
You should see Base62.java, JsonHelper.java, UrlShortener.java, and UrlStore.java listed in the output.
Don't see all four files?
Make sure you ran the file creation command from inside the url-shortener directory. If you're in the wrong folder, ls will show your Desktop files instead.
Check for typos in the filenames. Java is case-sensitive, so UrlShortener.java and urlshortener.java are different files.
Still stuck? Help me create my project files
Open the project in VS Code
- Open the url-shortener folder in VS Code by running:
code .
VS Code opens with your project folder loaded. In the Explorer sidebar on the left, you should see all four Java files listed.
code command not found?
If code is not recognized, open VS Code manually instead.
- Press Cmd+Space (macOS) or the Windows key (Windows) and type VS Code to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the url-shortener folder, and click Open.
Need help? How do I open a folder in VS Code?
Why open the folder and not individual files?
Opening the entire folder makes VS Code treat it as your workspace. The integrated terminal will start in the url-shortener directory, and IntelliSense can find all your classes automatically.
Your environment is ready and your project structure is in place. Next up, you'll write the core shortener logic and see your first API response.
Build the Shortener (Single-Threaded)
You have a project directory with empty files. Before worrying about scale or concurrency, you need a working API you can actually interact with.
This step delivers the core product: shorten a URL, get a code, visit that code, get redirected. Once this works, you can see the shortener in action from your terminal.
In this step, get ready to:
- Build a JSON helper and URL store to handle data.
- Create an HTTP server with shorten and redirect endpoints.
- Compile, run, and test the full flow with curl.
Implement the JSON helper
The server needs to read incoming JSON requests and build JSON responses. Since this project uses zero external dependencies, you will write a lightweight helper yourself.
- Open JsonHelper.java in VS Code and add the JSON parsing method by pasting this code:
public class JsonHelper {
// Extracts a string value from a simple JSON object by key
public static String extractValue(String json, String key) {
String searchKey = "\"" + key + "\"";
int keyIndex = json.indexOf(searchKey);
if (keyIndex == -1) {
return null;
}
int colonIndex = json.indexOf(':', keyIndex + searchKey.length());
if (colonIndex == -1) {
return null;
}
int start = json.indexOf('"', colonIndex + 1);
if (start == -1) {
return null;
}
int end = json.indexOf('"', start + 1);
if (end == -1) {
return null;
}
return json.substring(start + 1, end);
}
What does this code do?
This method manually parses a JSON string to find a key-value pair. It searches for the quoted key, finds the colon after it, then extracts the string value between the next pair of double quotes.
It returns null if the key is missing or the JSON structure is invalid. This is a simplified parser that works for flat JSON objects with string values.
- Below the extractValue method (but still inside the class), add the response builder methods:
// Builds the JSON response for a successful shorten request
public static String shortenResponse(String code, String shortUrl) {
return "{\"code\": \"" + code + "\", \"short_url\": \"" + shortUrl + "\"}";
}
// Builds the JSON response for stats requests
public static String statsResponse(String code, String originalUrl, long hits) {
return "{\"code\": \"" + code + "\", \"original_url\": \"" + escape(originalUrl) + "\", \"hits\": " + hits + "}";
}
// Builds a JSON error response
public static String error(String message) {
return "{\"error\": \"" + message + "\"}";
}
// Escapes special characters for safe JSON output
private static String escape(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
What do these methods do?
- shortenResponse builds the JSON the client sees after shortening a URL.
- statsResponse builds a JSON payload with the code, original URL, and hit count (used in a later step).
- error builds a consistent error format for all failure responses.
- escape prevents special characters in URLs from breaking the JSON structure.
- Save JsonHelper.java.
- Verify it compiles by running this command in your terminal:
javac JsonHelper.java
If compilation succeeds, you will see no output at all. The terminal returns silently to the prompt.
Seeing a compilation error?
- Check that every opening brace { has a matching closing brace }. The class itself needs a closing brace at the very end.
- Make sure you pasted the second chunk below the extractValue method, not inside it.
- Help me debug this compilation error
Implement the URL store
The store is where shortened URLs live. It maps a generated code to the original URL. For now, the code is simply the counter value as a string.
- Open UrlStore.java and add the storage logic by pasting this code:
import java.util.HashMap;
public class UrlStore {
private static final long START_ID = 1000;
// Plain counter - increments on each shorten call
private long counter = START_ID;
// Maps generated code to the original URL
private final HashMap<String, String> codeToUrl = new HashMap<>();
public String shorten(String originalUrl) {
long id = counter++;
String code = String.valueOf(id);
codeToUrl.put(code, originalUrl);
return code;
}
public String resolve(String code) {
return codeToUrl.get(code);
}
}
What does this code do?
- counter starts at 1000 and increments by one for each URL shortened. This gives each URL a unique numeric code.
- codeToUrl is a HashMap that stores the mapping from code to original URL.
- shorten() grabs the current counter value, converts it to a string, stores the mapping, and returns the code.
- resolve() looks up a code and returns the original URL (or null if not found).
- Save UrlStore.java.
- Verify both files compile together by running:
javac JsonHelper.java UrlStore.java
No output means success. You should see the prompt return silently.
Seeing 'cannot find symbol'?
- Make sure the import line import java.util.HashMap; is at the very top of UrlStore.java, above the class declaration.
- Check that both files are in the same directory.
- Help me debug this symbol error
Build the HTTP server
Now for the main event. Java includes a built-in HTTP server in the com.sun.net.httpserver package. You will wire it up with two endpoints: one to shorten URLs, one to redirect visitors.
- Open UrlShortener.java and add the server shell with the main method:
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
public class UrlShortener {
private static final int PORT = 8080;
private static final UrlStore store = new UrlStore();
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(PORT), 0);
server.createContext("/api/shorten", UrlShortener::handleShorten);
server.createContext("/", UrlShortener::handleRedirect);
server.setExecutor(null);
server.start();
System.out.println("URL Shortener running on http://localhost:" + PORT);
}
What does this code do?
- HttpServer.create() starts a server bound to port 8080.
- createContext() registers URL paths with handler methods. The server uses longest-prefix matching, so /api/shorten is matched before the catch-all /.
- setExecutor(null) means the server handles one request at a time on a single thread. This is intentional for now.
- Below the main method, add the handleShorten method that processes POST requests:
private static void handleShorten(HttpExchange exchange) throws IOException {
// Reject non-POST requests with 405
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
sendResponse(exchange, 405, JsonHelper.error("Method not allowed"));
return;
}
// Read and parse the request body
String body = readBody(exchange);
String url = JsonHelper.extractValue(body, "url");
if (url == null || url.isBlank()) {
sendResponse(exchange, 400, JsonHelper.error("Missing 'url' field"));
return;
}
// Shorten the URL and build the response
String code = store.shorten(url);
String shortUrl = "http://localhost:" + PORT + "/" + code;
String response = JsonHelper.shortenResponse(code, shortUrl);
exchange.getResponseHeaders().set("Content-Type", "application/json");
sendResponse(exchange, 201, response);
}
What does this handler do?
This method handles the shorten endpoint. It checks the HTTP method is POST, reads the JSON body, extracts the url field, stores it via UrlStore, and returns a 201 response with the generated short code.
If the method is wrong, it returns 405. If the URL field is missing, it returns 400.
- Below handleShorten, add the handleRedirect method that looks up short codes:
private static void handleRedirect(HttpExchange exchange) throws IOException {
// Only allow GET for redirects
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
sendResponse(exchange, 405, JsonHelper.error("Method not allowed"));
return;
}
// Extract the code from the URL path
String path = exchange.getRequestURI().getPath();
String code = path.substring(1);
if (code.isBlank()) {
sendResponse(exchange, 404, JsonHelper.error("Not found"));
return;
}
// Look up the original URL
String originalUrl = store.resolve(code);
if (originalUrl == null) {
sendResponse(exchange, 404, JsonHelper.error("Short code not found"));
return;
}
// Send a 302 redirect to the original URL
exchange.getResponseHeaders().set("Location", originalUrl);
exchange.sendResponseHeaders(302, -1);
exchange.close();
}
What does this handler do?
When someone visits a short URL like localhost:8080/1000, this handler strips the leading slash to get the code, looks it up in the store, and sends an HTTP 302 redirect with the original URL in the Location header.
The -1 in sendResponseHeaders(302, -1) means the response has no body. The browser follows the redirect automatically.
- Finally, below handleRedirect, add the two utility methods and close the class:
// Reads the full request body as a string
private static String readBody(HttpExchange exchange) throws IOException {
try (InputStream is = exchange.getRequestBody()) {
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
}
// Sends a JSON response with the given status code
private static void sendResponse(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
}
}
What do these utilities do?
- readBody() reads the entire request body into a string using a try-with-resources block that auto-closes the stream.
- sendResponse() converts the response string to bytes, sets the content type, writes the headers and body, then closes the output stream.
- Save UrlShortener.java.
- Compile all three files together by running:
javac *.java
No output means all three files compiled successfully.
Seeing compilation errors?
- If the error mentions cannot find symbol for sendResponse or readBody, make sure you added the utility methods chunk at the bottom of the file before the final closing brace.
- If the error mentions JsonHelper or UrlStore, confirm all three .java files are in the same directory.
- Help me debug this compilation error
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm your files contain each chunk in order:
- JsonHelper.java: extractValue method, then shortenResponse, statsResponse, error, and escape methods.
- UrlStore.java: import, class with counter, codeToUrl map, shorten(), and resolve().
- UrlShortener.java: imports, class with main, handleShorten, handleRedirect, readBody, and sendResponse.
If javac *.java returns silently with no output, everything is correct.
Run the server and test with curl
The code compiles. Time to see it work.
- Start the server by running:
java UrlShortener
You should see this message confirming the server is running:
URL Shortener running on http://localhost:8080
Why does the terminal hang?
The server is running in the foreground. It is listening for requests and will not return to the prompt until you stop it with Ctrl+C. Keep it running and open a second terminal for testing.
- Open a new terminal in VS Code by pressing Ctrl+Shift+` (macOS and Windows).
- Before you run the next command, predict: what HTTP status code and JSON fields do you expect back from a successful shorten request?
- Test shortening a URL by running:
curl -X POST -d '{"url": "https://example.com"}' http://localhost:8080/api/shorten
You should see a JSON response with a code and short URL:
{"code": "1000", "short_url": "http://localhost:8080/1000"}
Why is the code 1000?
The counter in UrlStore starts at 1000. The first URL gets code 1000, the next gets 1001, and so on. These are plain numeric strings for now. You will replace them with compact Base62 codes in a later step.
- Now predict: what happens when you visit the short code? What HTTP status and headers do you expect?
- Test the redirect by running:
curl -v http://localhost:8080/1000
In the verbose output, look for a 302 status code and a Location: https://example.com header. That confirms the redirect is working.
- Test that unknown codes return a 404 by running:
curl http://localhost:8080/doesnotexist
You should see the error response:
{"error": "Short code not found"}
Not seeing the expected responses?
- If you see Connection refused, make sure the server is still running in your first terminal. Look for the startup message.
- If you see a blank response from the POST, check that the -d flag includes valid JSON with a url key.
- If the redirect returns 404 instead of 302, make sure you use the exact code from the shorten response (1000 for the first request).
- Help me debug my URL shortener responses
Your single-threaded URL shortener is alive. You can shorten URLs, visit the short codes, and get redirected. Next up, you will break it on purpose by adding concurrency.
Break It With Concurrency
Your URL shortener works perfectly. But there is a hidden assumption baked in: only one request arrives at a time.
Real servers handle many requests simultaneously. When you switch to a thread pool, your HashMap and plain long counter will corrupt under concurrent access. You need to see this failure firsthand to understand why thread-safe structures exist.
In this step, get ready to:
- Switch the server from single-threaded to a 10-thread pool.
- Create a load test that fires 100 concurrent requests.
- Observe concurrency corruption: duplicate codes and lost entries.
Switch to a multi-threaded executor
Right now your server uses server.setExecutor(null), which means every request is handled on a single background thread. To simulate real-world traffic, you need a thread pool that handles multiple requests in parallel.
- In UrlShortener.java, find the import section at the top of the file. Add this new import below the existing ones:
import java.util.concurrent.Executors;
- Now scroll down to the main method and find this line:
server.setExecutor(null);
- Replace it with this line, which creates a pool of 10 threads:
server.setExecutor(Executors.newFixedThreadPool(10));
- Save UrlShortener.java.
What does this change?
With Executors.newFixedThreadPool(10), the server now dispatches incoming requests across 10 worker threads. Multiple requests can execute the shorten() method at the exact same time.
Your UrlStore still uses a plain HashMap and a long counter. Neither of these is designed for concurrent access. That is about to become a problem.
Create the load test script
You need a script that bombards your server with 100 simultaneous requests so you can see what happens when multiple threads read and write the same counter and map at the same time.
- Create a new file called LoadTest.java in your project folder. Add the imports, class declaration, and request-firing loop:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.List;
import java.util.HashSet;
public class LoadTest {
public static void main(String[] args) throws Exception {
// Create a shared HTTP client for all requests
HttpClient client = HttpClient.newHttpClient();
int totalRequests = 100;
// Latch blocks until all threads finish
CountDownLatch latch = new CountDownLatch(totalRequests);
// Thread-safe list to collect responses
List<String> responses = new CopyOnWriteArrayList<>();
for (int i = 0; i < totalRequests; i++) {
final int index = i;
// Fire each request on its own virtual thread
Thread.startVirtualThread(() -> {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8080/api/shorten"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"url\": \"https://example.com/page/" + index + "\"}"))
.build();
What does this code do?
This sets up 100 virtual threads that each send a POST request to your shortener. CountDownLatch is a synchronization tool that lets the main thread wait until all 100 requests complete before checking results.
CopyOnWriteArrayList is a thread-safe list for collecting responses. Each virtual thread fires its request independently, creating the exact kind of simultaneous access that breaks a plain HashMap.
- Add the remaining code directly below the previous block to handle responses, check for duplicates, and close the file:
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
responses.add(response.body());
} catch (Exception e) {
System.out.println("Request failed: " + e.getMessage());
} finally {
latch.countDown();
}
});
}
// Wait for all requests to finish
latch.await();
System.out.println("Total requests sent: " + totalRequests);
System.out.println("Total responses received: " + responses.size());
// Check for duplicates using a HashSet
HashSet<String> unique = new HashSet<>(responses);
System.out.println("Unique responses: " + unique.size());
if (unique.size() < responses.size()) {
System.out.println("DUPLICATES DETECTED! Lost " + (responses.size() - unique.size()) + " entries");
} else {
System.out.println("All codes unique - no concurrency issues!");
}
}
}
What does this section do?
After all 100 threads finish, the code dumps all response bodies into a HashSet. A set only stores unique values. If the set is smaller than the list, some requests got the same response, meaning your counter produced duplicate codes.
latch.countDown() in the finally block ensures the latch decrements even if a request fails. Once all 100 count down, latch.await() unblocks and the analysis runs.
- Save LoadTest.java.
Seeing red squiggles in VS Code?
Make sure the two code blocks are joined together seamlessly. The HttpResponse<String> response = client.send(request, line should sit directly below the .build(); line with no gap.
Check that all braces are balanced: the try block closes, the lambda closes with });, the for loop closes, then the method and class close.
Help me fix my LoadTest.java file
✔️ Awesome, I've got everything!
Double check you have saved LoadTest.java.
ⓧ I'd like to double check the full code
Scroll up and confirm both code blocks are present in order:
- Block 1: imports through the .build(); line inside the for loop.
- Block 2: the client.send() call, catch/finally, the analysis with HashSet, and the closing braces for the method and class.
The two blocks should join together into one continuous file with no gaps or duplicate lines.
Run the load test and observe the corruption
Time to see your shortener break under pressure. You will compile everything, restart the server, and fire the load test.
- If your server is still running from the previous step, stop it by pressing Ctrl+C in that terminal.
- Recompile all files by running:
javac *.java
- Start the server again by running:
java UrlShortener
You should see URL Shortener running on http://localhost:8080 printed in the terminal.
- Open a second terminal. You need one terminal running the server and another to run the load test.
Before you run the load test, make a prediction. You are sending 100 unique URLs to a counter that increments with counter++. Do you think all 100 responses will have unique codes, or will some be duplicates?
- Run the load test in the second terminal:
java LoadTest
You will see output like this:
Total requests sent: 100
Total responses received: 100
Unique responses: 94
DUPLICATES DETECTED! Lost 6 entries
Your exact numbers will vary because concurrency bugs are non-deterministic. You might see 2 lost entries or 15. You might even see request failures or exceptions. The point is the same: your data is corrupted.
Why does this happen?
The expression counter++ is not a single operation. It reads the current value, adds one, then writes it back. When two threads read the same value before either writes, both generate the same code. This is called a race condition.
HashMap has the same problem. Under concurrent writes, it can lose entries, produce corrupted internal state, or even enter infinite loops during internal rehashing. These bugs are silent in testing and catastrophic in production.
Seeing "All codes unique" instead?
Race conditions are non-deterministic. Run the load test 3-5 more times. On most runs you will see duplicates. If you consistently see zero duplicates, make sure your UrlShortener.java has Executors.newFixedThreadPool(10) and not null.
Also confirm your UrlStore.java still uses a plain HashMap and long counter. If you already switched to ConcurrentHashMap or AtomicLong, revert those changes for now.
Help me reproduce the concurrency bug
You just proved that your shortener breaks under real-world conditions. The HashMap and counter++ pattern cannot survive concurrent access. In the next step, you will fix both with thread-safe primitives that solve this problem without explicit locking.
Fix It With Thread-Safe Primitives
You just watched your URL shortener break under concurrent load. The duplicates and lost entries happened for two specific reasons: HashMap is not designed for concurrent writes, and counter++ is not atomic. Two threads can read the same counter value before either writes back, producing duplicate codes.
Java provides purpose-built replacements that solve both problems without explicit locking. ConcurrentHashMap allows safe simultaneous reads and writes. AtomicLong guarantees that every increment produces a unique value, even when 10 threads call it at the same instant. These are the exact primitives production Java services use.
In this step, get ready to:
- Replace the unsafe HashMap and counter with ConcurrentHashMap and AtomicLong.
- Rerun the load test and confirm zero duplicates under concurrent traffic.
Replace HashMap and counter with thread-safe primitives
The fix lives entirely in UrlStore.java. You need to swap three things: the import, the data structure, and the counter type.
- Open UrlStore.java in VS Code.
- Select all the existing code and replace it with the following:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
public class UrlStore {
private static final long START_ID = 1000;
// AtomicLong guarantees unique values across threads
private final AtomicLong counter = new AtomicLong(START_ID);
// ConcurrentHashMap allows safe simultaneous reads and writes
private final ConcurrentHashMap<String, String> codeToUrl = new ConcurrentHashMap<>();
public String shorten(String originalUrl) {
long id = counter.getAndIncrement();
String code = String.valueOf(id);
codeToUrl.put(code, originalUrl);
return code;
}
public String resolve(String code) {
return codeToUrl.get(code);
}
}
What changed from the broken version?
- HashMap became ConcurrentHashMap. This uses fine-grained locking internally, allowing multiple threads to read and write different entries simultaneously without corrupting the map.
- long counter became AtomicLong counter. The CPU performs the read-and-increment as a single indivisible operation, so two threads can never read the same value.
- counter++ became counter.getAndIncrement(). This returns the current value and increments atomically in one step.
- Save UrlStore.java.
Seeing a red underline in VS Code?
If VS Code highlights the imports, make sure you typed the full package paths: java.util.concurrent.ConcurrentHashMap and java.util.concurrent.atomic.AtomicLong. These are built into the JDK, so no external library is needed.
Help me fix import errors in UrlStore.java
Recompile and verify the fix
The code change is done. Now you need to prove it actually works under the same concurrent pressure that broke it before.
- Switch back to the terminal where your server is running.
- Press Ctrl+C to stop the server.
- Recompile all files by running:
javac *.java
- Start the server again by running:
java UrlShortener
You should see URL Shortener running on http://localhost:8080 printed in the terminal.
- Open a second terminal window.
Before you run the load test, think about what you expect to happen. Last time, the test showed duplicates. You swapped two data structures. Do you think all 100 requests will return unique codes this time, or will some other failure appear?
- Run the load test by running:
java LoadTest
You should see output ending with:
All codes unique - no concurrency issues!
All 100 requests returned unique codes. The same test that broke your server moments ago now passes cleanly.
Why did this fix the problem?
AtomicLong uses CPU-level compare-and-swap instructions. When two threads call getAndIncrement() at the same instant, the hardware guarantees one completes first and the second sees the updated value. No two threads can ever get the same ID.
ConcurrentHashMap uses lock striping internally. Different threads writing to different keys proceed in parallel without blocking each other. This is the standard choice for read-heavy services like URL shorteners.
Still seeing duplicates?
Make sure you stopped the old server before starting the new one. If the old process is still running on port 8080, your recompiled code never actually started.
Confirm the import at the top of UrlStore.java says java.util.concurrent.ConcurrentHashMap and not java.util.HashMap. Also verify that the counter field uses new AtomicLong(START_ID) and not a plain long.
Help me debug why my load test still shows duplicates after switching to ConcurrentHashMap
✔️ Awesome, I've got everything!
Great. Double check you have saved UrlStore.java and confirmed zero duplicates from the load test.
ⓧ I'd like to double check the full code
Here is the complete UrlStore.java as it should look now:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
public class UrlStore {
private static final long START_ID = 1000;
private final AtomicLong counter = new AtomicLong(START_ID);
private final ConcurrentHashMap<String, String> codeToUrl = new ConcurrentHashMap<>();
public String shorten(String originalUrl) {
long id = counter.getAndIncrement();
String code = String.valueOf(id);
codeToUrl.put(code, originalUrl);
return code;
}
public String resolve(String code) {
return codeToUrl.get(code);
}
}
Your server now handles 100 simultaneous requests without a single duplicate. The concurrency bug is gone. Next up, you will replace those ugly numeric codes with compact Base62 strings and add a stats endpoint to track how many times each short link gets clicked.
Add Base62 Encoding and Stats
Your shortener works and handles concurrent traffic safely. But the codes it produces are plain numbers like 1000, 1001, 1002. They're ugly, predictable, and waste characters.
Production URL shorteners like TinyURL use Base62 encoding to turn numbers into compact alphanumeric codes like q0U. In this step, you'll implement that encoding and add a stats endpoint that tracks how many times each short link gets clicked.
In this step, get ready to:
- Implement Base62 encoding to produce compact, URL-safe codes.
- Add hit-count tracking to every short link using atomic counters.
- Build a stats endpoint that reports how many times a code has been visited.
Implement Base62 encoding
Base62 uses 62 characters (digits 0-9, uppercase A-Z, lowercase a-z) to represent numbers. A 6-character Base62 code supports over 56 billion unique values while staying URL-safe with no special characters.
- Open Base62.java in VS Code and paste the following code:
public class Base62 {
// 62 URL-safe characters: digits + uppercase + lowercase
private static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
private static final int BASE = ALPHABET.length();
// Convert a number to a compact Base62 string
public static String encode(long number) {
if (number == 0) {
return String.valueOf(ALPHABET.charAt(0));
}
StringBuilder sb = new StringBuilder();
while (number > 0) {
sb.append(ALPHABET.charAt((int) (number % BASE)));
number /= BASE;
}
return sb.reverse().toString();
}
// Convert a Base62 string back to its numeric value
public static long decode(String code) {
long result = 0;
for (int i = 0; i < code.length(); i++) {
result = result * BASE + ALPHABET.indexOf(code.charAt(i));
}
return result;
}
}
What does this code do?
- encode(long) repeatedly divides the number by 62, mapping each remainder to a character in the alphabet. The result is reversed because the least-significant digit is computed first.
- decode(String) does the inverse, multiplying the accumulated result by 62 for each character and adding its position in the alphabet.
- Starting from counter value 1000, the first code produced will be G8 instead of the plain string 1000.
- Save Base62.java.
- Verify it compiles cleanly by running:
javac Base62.java
You should see no output. No output means no errors.
Seeing a compilation error?
- Check that the class name is exactly Base62 and matches the filename.
- Make sure the ALPHABET string has exactly 62 characters. Count: 10 digits + 26 uppercase + 26 lowercase.
- help me fix my Base62.java compilation error
Upgrade UrlStore with Base62 and hit tracking
Now you need to wire Base62 into your store and add hit-count tracking. Every time someone visits a short link, the hit counter for that code increments atomically.
- Replace the entire contents of UrlStore.java with the following. First, paste the imports, fields, and updated shorten() method:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
public class UrlStore {
private static final long START_ID = 1000;
private final AtomicLong counter = new AtomicLong(START_ID);
private final ConcurrentHashMap<String, String> codeToUrl = new ConcurrentHashMap<>();
// Track hits per code with an atomic counter
private final ConcurrentHashMap<String, AtomicLong> codeToHits = new ConcurrentHashMap<>();
public String shorten(String originalUrl) {
long id = counter.getAndIncrement();
// Encode the numeric ID as a compact Base62 string
String code = Base62.encode(id);
codeToUrl.put(code, originalUrl);
codeToHits.put(code, new AtomicLong(0));
return code;
}
- Directly below the shorten() method, add the lookup and stats methods, then close the class:
// Resolve a code and increment its hit counter
public String resolve(String code) {
String url = codeToUrl.get(code);
if (url != null) {
codeToHits.get(code).incrementAndGet();
}
return url;
}
// Look up URL without incrementing hits (for stats display)
public String getOriginalUrl(String code) {
return codeToUrl.get(code);
}
// Return the current hit count for a code
public long getHits(String code) {
AtomicLong hits = codeToHits.get(code);
return hits != null ? hits.get() : 0;
}
}
What changed in UrlStore?
- A new codeToHits map stores an AtomicLong per code so hit counts are thread-safe.
- shorten() now calls Base62.encode(id) instead of String.valueOf(id) and initializes the hit counter to zero.
- resolve() increments the counter on every access. getOriginalUrl() returns the URL without incrementing, so the stats endpoint doesn't inflate the count.
- Save UrlStore.java.
- Stop the running server with Ctrl+C in your terminal, then compile and restart by running:
javac *.java && java UrlShortener
- Open a second terminal and test the shorten endpoint by running:
curl -X POST -d '{"url": "https://example.com"}' http://localhost:8080/api/shorten
You should see a JSON response with a Base62 code like G8 instead of the old plain number 1000. Letters and digits, no special characters.
Still seeing plain numbers like 1000?
- Make sure you saved both Base62.java and UrlStore.java before recompiling.
- Check that shorten() calls Base62.encode(id) not String.valueOf(id).
- my shorten endpoint still returns plain numbers after adding Base62
Add the stats endpoint
You have hit tracking in the store. Now you need an HTTP endpoint that exposes those stats as JSON.
- In UrlShortener.java, find this line inside the main method:
server.createContext("/", UrlShortener::handleRedirect);
- Add this new context registration directly above that line:
server.createContext("/api/stats/", UrlShortener::handleStats);
Why does order matter here?
The HttpServer uses longest-prefix matching. Registering /api/stats/ before / ensures stats requests match the stats handler, not the redirect catch-all.
- Now add the handleStats method. Paste it below the handleRedirect method:
private static void handleStats(HttpExchange exchange) throws IOException {
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
sendResponse(exchange, 405, JsonHelper.error("Method not allowed"));
return;
}
// Extract the code from the path: /api/stats/{code}
String path = exchange.getRequestURI().getPath();
String code = path.substring("/api/stats/".length());
if (code.isBlank()) {
sendResponse(exchange, 400, JsonHelper.error("Missing code"));
return;
}
// Look up the URL without incrementing hits
String originalUrl = store.getOriginalUrl(code);
if (originalUrl == null) {
sendResponse(exchange, 404, JsonHelper.error("Short code not found"));
return;
}
long hits = store.getHits(code);
String response = JsonHelper.statsResponse(code, originalUrl, hits);
exchange.getResponseHeaders().set("Content-Type", "application/json");
sendResponse(exchange, 200, response);
}
What does handleStats do?
- It extracts the code from the URL path by stripping the /api/stats/ prefix.
- It calls getOriginalUrl() (not resolve()) so viewing stats doesn't inflate the hit count.
- The response uses JsonHelper.statsResponse() which you already have in your helper class.
- Save UrlShortener.java.
- Stop the server with Ctrl+C, recompile, and restart by running:
javac *.java && java UrlShortener
Compilation error in UrlShortener?
- If you see "cannot find symbol" for getOriginalUrl or getHits, make sure you saved the updated UrlStore.java with those methods.
- Check that the createContext line references UrlShortener::handleStats (not handleStat or any other typo).
- help me fix my UrlShortener compilation error after adding handleStats
Test the full flow
Before you run the final test, think about what you expect to see. You'll shorten a URL, visit the short link a few times, then check stats.
- In your second terminal, shorten a URL by running:
curl -X POST -d '{"url": "https://example.com"}' http://localhost:8080/api/shorten
You should see a JSON response with a short Base62 code. Note the code value (e.g. G8).
- Visit the short link twice to generate hits. Replace G8 with the code you received:
curl -v http://localhost:8080/[[CODE="the Base62 code from your shorten response"]]
curl -v http://localhost:8080/[[CODE="the Base62 code from your shorten response"]]
Each request should show a 302 redirect with a Location: https://example.com header.
- Do you think the hit count will say 2 or some other number? Check by running:
curl http://localhost:8080/api/stats/[[CODE="the Base62 code from your shorten response"]]
You should see JSON with "hits": 2 matching the two visits you just made.
Why 2 and not 3?
The stats endpoint calls getOriginalUrl() which does NOT increment the hit counter. Only resolve() (used by the redirect handler) increments it. Checking stats never inflates the count.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files: Base62.java, UrlStore.java, and UrlShortener.java.
ⓧ I'd like to double check the full code
Scroll up and confirm each section is present in order. Your project should now have these completed chunks:
- Base62.java: the ALPHABET constant, encode(), and decode() methods.
- UrlStore.java: the codeToHits map, updated shorten() with Base62, resolve() with hit increment, getOriginalUrl(), and getHits().
- UrlShortener.java: the new createContext("/api/stats/", ...) registration and the handleStats method.
Your URL shortener now produces compact, professional-looking codes and tracks every visit. You've implemented the same encoding algorithm that production URL shorteners use. Next up, you'll extend this into a Secret Mission by building a rate limiter to protect your API from abuse.
Secret mission
Build a Rate Limiter
Your URL shortener works, but any public API needs protection from abuse. A single client could fire thousands of requests and exhaust your server. In this mission, you'll build an in-memory rate limiter that blocks abusive clients after 10 requests per minute, using the same ConcurrentHashMap patterns you just mastered.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no paid services, so there are no ongoing costs.
Resources you used:
- The URL shortener server process (running on port 8080).
- The project directory on your Desktop containing Java source files and compiled class files.
Keep everything running
No action needed. Choose this if you want to keep experimenting with your URL shortener, add new endpoints, or try different concurrency patterns.
- Your server stays running and accepting requests on port 8080.
- All shortened URLs remain in memory as long as the server runs.
- Your source files remain in the project directory on your Desktop.
Pause - I'll come back to this later
Shut down the running server to free up the port. Your source files stay on disk so you can restart anytime.
- Switch to the terminal where the server is running and press Ctrl+C to stop it.
- All in-memory data (shortened URLs, hit counts) is lost when the server stops. This is expected for an in-memory store.
- To restart later, open a terminal in your project directory and run:
javac *.java
java UrlShortener
Your server picks right back up on port 8080.
Delete - I don't want to use this again
Remove all project files and stop the server completely.
- Switch to the terminal where the server is running and press Ctrl+C to stop it.
- Delete the project directory by running these commands:
macOS
cd ~/Desktop
rm -rf url-shortener
Windows
cd ~\Desktop
rmdir /s /q url-shortener
That removes all source files, compiled class files, and the project folder itself.
That's a Wrap!
That's a Wrap!
Nice work. You just built a production-grade URL shortener API in pure Java with zero external dependencies.
You've learned how to:
- Build a REST API from scratch using Java's built-in HttpServer, handling routing, status codes, and JSON responses with no frameworks.
- Experience concurrency bugs firsthand by running a HashMap under concurrent load, then fix the corruption with ConcurrentHashMap and AtomicLong.
- Implement Base62 encoding for compact URL codes and atomic hit-count tracking with a stats endpoint.
- Secret Mission: Built a rate limiter using ConcurrentHashMap.compute() for atomic windowed counting that blocks abusive clients.
Ready to quiz yourself?