Snips URL Shortener - Technical Interview Prep Guide
Table of Contents
- Project Overview
- Architecture Map
- Tech Stack, Justified
- Feature-by-Feature Breakdown
- Database Schema Walkthrough
- Weak Points & Honest Gaps
1. Project Overview
Snips is a full-stack, analytics-rich URL shortener application. It allows users to take long URLs and convert them into short, manageable links (either randomly generated or custom slugs). Beyond basic redirection, it features advanced link management: users can password-protect links, set expiration dates, and manually pause/resume them. Every click is tracked, providing the user with detailed analytics including timeseries data, geographic location (country/city), referring domains, and device/browser statistics. The platform is secured by a robust JWT-based authentication system utilizing Email verification and One-Time Passwords (OTPs) via Redis.
2. Architecture Map
The application follows a standard modern decoupled client-server architecture.
Directory Structure:
snips/
├── frontend/ (React / Vite)
│ ├── src/pages/ (View components: Home, Login, LinkAnalytics, etc.)
│ └── src/components/ (Reusable UI components: LinkList, EditLinkModal)
└── backend/ (Express / Node.js)
├── controller/ (Business logic: analytics, link, redirect, user)
├── config/ (Setup: db, redis, mail, analytics tracking)
├── middlewares/ (Auth, CSRF, error handling)
└── prisma/ (Database schema & migrations)Request Flow (Example: User clicks a short link)
[Client Browser] -> GET /:slug
|
v
[Express Router] -> `redirectController.js` (handleRedirect)
|
+--> 1. Check [Redis Cache] for `slug:{slug}`
| - If Hit: return link data immediately
| - If Miss: query [PostgreSQL] via Prisma, then save to Redis
|
+--> 2. Validate Link (Is active? Expired? Password Protected?)
|
+--> 3. Return 302 Redirect to Client Browser (Fast response)
|
+--> 4. (Background / Non-Blocking)
`trackClick()` calls `ip-api.com` for geolocation
Calculates IP Hash, User Agent parses (Browser, OS, Device)
Executes Prisma Transaction to log `ClickEvent` in [PostgreSQL]3. Tech Stack, Justified
Note: Versions inferred directly from package.json.
Frontend
- React (v19) & Vite (v7): Vite was chosen over Create React App or Webpack for its insanely fast Hot Module Replacement (HMR) and optimized esbuild compilation. React handles the UI state.
- Tailwind CSS (v4): Utility-first CSS framework. Chosen for rapid UI development without context-switching between JS and CSS files.
- Recharts (v3.9): Chosen for rendering analytics dashboards (Timeseries, Device types). It provides composable React components that are easy to customize compared to canvas-based libraries like Chart.js.
- React Router DOM (v7): Handles client-side routing.
Backend
- Express (v5.2): The core web framework. Chosen for its simplicity, massive ecosystem of middlewares (like
helmet,cors,cookie-parser), and flexibility. - PostgreSQL & Prisma (v7): Relational DB paired with a modern, type-safe ORM. Postgres was chosen because the data is highly relational (Users -> Links -> ClickEvents). Prisma provides an auto-generated query builder that makes complex analytic queries (e.g., grouping by day/device) much easier than writing raw SQL.
- Redis (v5): In-memory data structure store. Absolutely critical in this stack for three distinct purposes:
- Caching: Read-aside caching for URL redirects to prevent hitting Postgres on every click.
- Rate Limiting / Throttling: Preventing brute-force OTP requests.
- Ephemeral State: Storing OTPs, Verification Tokens, and CSRF tokens with automatic TTL (Time-To-Live) expiration.
- Bcrypt (v6): Used to securely hash both user account passwords and password-protected URLs.
- Zod (v4): Schema validation for incoming request payloads (e.g.,
createLinkSchema). Guarantees type safety at the API boundary before hitting the database.
4. Feature-by-Feature Breakdown
4.1 URL Redirection & Redis Caching
- What it does: Takes a short slug (e.g.,
/my-link) and redirects the user to the destination URL. - Concept: HTTP Redirection and Read-Aside Caching. The app issues a
302 Found(Temporary) redirect instead of a301 Moved Permanently. A 301 would cause the browser to cache the redirect locally, meaning subsequent clicks wouldn't hit the server, breaking our analytics. Redis acts as a high-speed middleman. - Implementation:
backend/controller/redirectController.js->getLinkData()const getLinkData = async (slug) => { const cached = await redisClient.get(`slug:${slug}`); if (cached) return JSON.parse(cached); // Cache Hit const link = await prisma.link.findUnique({ where: { slug } }); if (!link) return null; const cacheData = { linkId: link.id, originalUrl: link.originalUrl, /* ... */ }; redisClient.set(`slug:${slug}`, JSON.stringify(cacheData), { EX: 60 * 60 * 24 }); // 24h TTL return cacheData; }; - Why this approach: If a link goes viral, hitting the Postgres database for every single click would overwhelm the connection pool. Redis responds in microseconds.
- Scaling 100x: Redis memory could fill up. We would need to implement an eviction policy (like
allkeys-lru) and likely scale out to a Redis Cluster. - Interview Qs:
- Why a 302 redirect instead of 301? (For accurate analytics tracking).
- What happens to the cache if a user edits their link? (We actively invalidate it by calling
await redisClient.del(slug:${link.slug})in the update controller).
4.2 Asynchronous Analytics Tracking
- What it does: Silently records data about the user clicking the link (Browser, OS, Country, IP Hash) without slowing down their redirect.
- Concept: Background Processing / Non-blocking IO. By purposely not using the
awaitkeyword, the Node event loop can send the HTTP response to the user immediately, while the promise resolves in the background. - Implementation:
backend/config/analytics.js(trackClick) andredirectController.jsInside// In handleRedirect: res.redirect(302, linkData.originalUrl); // Track click AFTER sending response. No await here. trackClick(linkData.linkId, req).catch(console.error);trackClick, a Prisma Transaction is used:await prisma.$transaction([ prisma.clickEvent.create({ data: { /* device, browser, ipHash etc */ } }), prisma.link.update({ where: { id: linkId }, data: { totalClicks: { increment: 1 } } }) ]); - Why this approach: Gathering geolocation requires an external HTTP call to
ip-api.com. If we blocked the redirect waiting for this API, the user experience would suffer greatly (slow redirects). We also use SHA256 IP Hashing to count unique clicks without storing raw PII (Personally Identifiable Information). - Scaling 100x: The background promise approach will overwhelm Node's memory and Postgres's write capacity at scale. We would need to decouple this by publishing click events to a message broker (like Kafka or RabbitMQ) and having a separate worker service batch-insert them into the database.
- Interview Qs:
- Is there a race condition when updating
totalClicks? (No, Prisma's{ increment: 1 }translates to an atomic SQL update). - What happens if the
ip-api.comcall fails? (The code wraps it in a try-catch and gracefully degrades—the click is still recorded, just withnulllocation data).
- Is there a race condition when updating
4.3 Two-Step Authentication (OTP)
- What it does: Users log in using their password, and are then sent a 6-digit One Time Password to their email to complete the login.
- Concept: Multi-Factor Authentication (MFA). It utilizes Redis for short-lived ephemeral state management via Time-To-Live (TTL) keys.
- Implementation:
backend/controller/userController.jsconst otp = crypto.randomInt(100000, 999999).toString(); const otpKey = `otp:${email}`; // Store in Redis with a 5-minute (300s) expiration await redisClient.set(otpKey, JSON.stringify({ email, otp }), { EX: 300 }); - Why this approach: We could store OTPs in the Postgres database, but we would have to run cleanup cron-jobs to delete expired tokens. Redis handles expiration automatically and natively, making it the perfect tool for this.
- Scaling 100x: Currently,
sendMailisawaited synchronously in the controller. This forces the client to wait for the SMTP server to respond. At scale, this should be offloaded to an asynchronous background queue (like BullMQ). - Interview Qs:
- How do you prevent a brute-force attack on the 6-digit OTP? (We use an
otp-attempts:${email}counter in Redis that increments on every try. If it exceeds 5, the OTP key is aggressively deleted, forcing a new request).
- How do you prevent a brute-force attack on the 6-digit OTP? (We use an
4.4 Password-Protected Links
- What it does: Allows users to lock a link behind a password. Visitors must enter the password to be redirected.
- Concept: Cryptographic Hashing. Link passwords are treated with the exact same security as user account passwords.
- Implementation:
backend/controller/linkController.js// On Creation const hashedPassword = password ? await bcrypt.hash(password, 10) : null; - Why this approach: If the database is compromised, attackers cannot see the plain-text passwords protecting private user links.
- Interview Qs:
- Why use Bcrypt with a salt round of 10? (Bcrypt is intentionally slow to resist GPU brute-force attacks. A salt prevents rainbow table attacks. 10 is a standard balance between security and server performance).
4.5 CSRF Protection Middleware
- What it does: Protects against Cross-Site Request Forgery, ensuring requests made to the API originated from the actual frontend application.
- Concept: Synchronizer Token Pattern. The server generates a random token, stores it in Redis, and sends it to the client via a Cookie. The client must read this cookie and attach it as a Header (
x-csrf-token) on subsequent state-changing requests. - Implementation:
backend/middlewares/csrfMiddleware.js. Validates thatreq.headers["x-csrf-token"] === storedToken_in_redis.
5. Database Schema Walkthrough
The schema is defined in backend/prisma/schema.prisma using PostgreSQL.
UserTable:id(UUID),name,email(@unique),password(hashed).- 1-to-Many relationship with
Link.
LinkTable:slug(@unique) - The short ID used in the URL. Indexed for fast lookup.originalUrl- Where it points to.userId- Foreign key linking to the creator. Index added (@@index([userId])) because we frequently query "Get all links for user X".password,expiresAt,isActive- Metadata controlling redirect behavior.totalClicks- An aggregate cache to avoid counting theClickEventtable every time we want the total.
ClickEventTable:- Tracks individual clicks for analytics.
linkId- Foreign key linking to theLink. Set toonDelete: Cascadeso deleting a link wipes its analytics automatically.ipHash,country,browser,os,device- Extracted from the request.- Indexes:
@@index([linkId, clickedAt])- Crucial composite index. ThegetTimeseriesanalytics controller heavily filters and groups by these two exact columns.
6. Weak Points & Honest Gaps
If an interviewer presses for system limitations, bring these up proactively to show seniority and architectural awareness.
- Synchronous Third-Party Calls: In
trackClick, we doawait fetch("http://ip-api.com/..."). If that free API goes down or rate-limits the server, it will bottleneck the Node event loop's background tasks. Fix: Use a local GeoIP database (like MaxMind GeoLite2) in memory, avoiding network calls entirely. - Cache Stampede (Thundering Herd): If a highly popular link's Redis cache expires, thousands of concurrent requests might hit the
handleRedirectcontroller simultaneously. They will all see a cache miss and all query Postgres for the same row before the first request can repopulate the cache. Fix: Implement a cache-lock/debounce mechanism. - Blocking Email Dispatch: Registration and OTP endpoints
await sendMail(). Email protocols (SMTP) are notoriously slow. If the mail server is sluggish, the user's browser hangs on a loading spinner. Fix: Push email jobs to a Redis-backed queue like BullMQ and return a202 Acceptedto the user immediately. - Rate Limiter Implementation: The custom rate limiter in
userController.js(usingresend-otp-limit) functions more like a hard debounce. It locks the user out completely for exactly 60 seconds after an action, rather than using a standard Token Bucket or Sliding Window algorithm. (Though the app does useexpress-rate-limitglobally inindex.js). - Analytics Payload Scaling: The
ClickEventtable will grow massively over time. Storing raw click events in Postgres is fine for an MVP, but at millions of clicks, timeseries aggregations will slow down. Fix: A time-series database like InfluxDB or ClickHouse would be much better suited for this data.