URL shortener system design interview: a step-by-step walkthrough with numbers and a diagram

The eight steps of the round in order, with the arithmetic worked out, three ways to generate short codes compared, and what each level from SDE-1 to SDE-3 is expected to cover.

Key takeaways

  • Run the round in eight steps: requirements, estimates, API, data model, short codes, high-level design, deep dives, trade-offs.
  • Do the arithmetic aloud: 100 million links a day is about 1,160 writes a second, 11,600 reads a second and 91 TB over five years.
  • Seven base62 characters give about 3.5 trillion codes. Six give 56.8 billion, which runs out in about a year and a half at this scale.
  • Return 302 when you need accurate click counts. Browsers can cache a 301, so repeat clicks may never reach you.
  • Keep the redirect path short: cache, key lookup, redirect. Clicks go through a queue.

How does a URL shortener system design interview run?

The interviewer says “Design a URL shortener, like TinyURL or Bitly” and waits. The question fits in one round and still has real depth: ID generation, a read-heavy cache, sharding, and a choice between two HTTP status codes that changes what the business can measure.

What gets graded is how you drive. A good round follows a fixed order, and each step produces something the next one uses.

StepMinutesWhat you produce
1. Requirements4–5Functional and non-functional list, agreed with the interviewer
2. Estimates4–5Reads and writes per second, storage, cache size
3. API3POST /urls, GET /{code} and the redirect status
4. Data model3One main table and its key
5. Short codes5–7A generation scheme and the code length
6. High-level design5–7One diagram with a read path and a write path
7. Deep dives10–12Two or three areas the interviewer picks
8. Trade-offs3–5Bottlenecks, failures, what you'd change

In a 60-minute round, spend the extra time on deep dives. If the interviewer pushes you somewhere else, follow them: the order is a default, not a script.

Step 1: what are the functional and non-functional requirements?

Start by asking, not drawing:

  • Can anyone shorten a link, or only signed-in users with API keys?
  • Can users choose a custom alias, like /diwali-sale?
  • Do links expire? Do we need click analytics, and how fresh?
  • How many new links a day, and how many clicks per link?
  • Can a link's destination change after it is created?

Then say the agreed list back. The 50 ms target and the 100 million links a day at 10 clicks per link are the numbers Helpwing's own URL-shortener demo uses. If the interviewer gives none, propose these and ask whether they are reasonable.

Functional requirements

  1. Turn a long URL into a short link.
  2. Redirect a short link to its original URL.
  3. Let users set a custom alias and an expiry date.
  4. Show click analytics for each link.

Out of scope, said aloud: editing a link's destination, QR codes, link-in-bio pages.

Non-functional requirements

  • Latency: redirects in under 50 ms at our servers, at the 99th percentile.
  • Availability: a dead short link is a broken link in someone's email or printed poster. Aim for 99.99% on redirects, about 53 minutes of downtime a year.
  • Scale: 100 million new links a day, 10 clicks for every new link.
  • Consistency: a link works the moment the API returns it. Analytics can lag by a few minutes.
  • Security: codes are not guessable in sequence, so nobody can walk through other people's links.

Step 2: back-of-the-envelope estimates, with the maths

Say each step aloud. A day has 86,400 seconds; rounding it to 100,000 makes mental maths easy and only makes per-second figures about 14% low.

  • Writes: 100,000,000 ÷ 86,400 ≈ 1,160 a second. Assume the peak is three times the average: about 3,500 a second.
  • Reads: 10 × 100 million = 1 billion redirects a day, or about 11,600 a second, and about 35,000 at peak.
  • Links in five years: 100 million × 365 × 5 = 182.5 billion.
  • One record: a 7-byte code, a long URL averaging 100–200 bytes (up to 2,048), timestamps, an owner ID and index overhead. Round up to 500 bytes.
  • Storage: 182.5 billion × 500 bytes ≈ 91 TB, or about 274 TB with three replicas.
  • Cache: clicks follow a power law, so assume the hottest 20% of links take about 80% of clicks. If all 1 billion daily clicks were on different links, 20% is 200 million entries × 500 bytes = 100 GB. That fits on four 32 GB cache nodes, each with a replica.
  • Bandwidth: about 0.6 MB a second in and 5.8 MB a second out. Not a bottleneck; say so and move on.
QuantityWorkingResult
Writes per second100M ÷ 86,400~1,160 average, ~3,500 peak
Reads per second1B ÷ 86,400~11,600 average, ~35,000 peak
Links in 5 years100M × 365 × 5182.5 billion
Storage in 5 years182.5B × 500 bytes~91 TB, ~274 TB with 3 replicas
Cache200M × 500 bytes~100 GB

Estimates only earn their time if they change a decision. The 10:1 read ratio says put a cache in front. 91 TB says one database server won't hold it: shard. 1,160 writes a second is modest, so any code scheme in step 5 can keep up.

Step 3: API design, POST /urls and GET /{code}

Two endpoints carry the system:

POST /urls
Authorization: Bearer <api-key>
Content-Type: application/json

{
  "long_url": "https://example.com/blog/2026/10/a-very-long-path?utm_source=mail",
  "custom_alias": "diwali-sale",
  "expires_at": "2026-12-31T23:59:59Z"
}

201 Created
{
  "code": "diwali-sale",
  "short_url": "https://s.example/diwali-sale",
  "expires_at": "2026-12-31T23:59:59Z"
}

GET /aZ3kP9q

302 Found
Location: https://example.com/blog/2026/10/a-very-long-path?utm_source=mail
  • custom_alias and expires_at are optional. Without an alias, the server generates a 7-character code.
  • Create errors: 400 for an invalid URL, 409 Conflict if the alias is taken, 429 Too Many Requests over the rate limit.
  • Redirect errors: 404 Not Found for an unknown code, 410 Gone for an expired one.
  • If time allows: GET /urls/{code}/stats and DELETE /urls/{code} for the owner.

301 or 302: which redirect should a URL shortener return?

Interviewers ask this to check that you understand HTTP caching. Under RFC 9110, a 301 Moved Permanently response is cacheable by default: a browser may store it and send repeat clicks straight to the destination. A 302 Found is not stored unless your headers allow it, so every click comes back to you.

301 Moved Permanently302 Found
Cached by browsers by defaultYes, unless headers say otherwiseNo, unless headers allow it
Click analyticsMisses repeat clicksCounts every click
Change or expire a link laterBrowsers that cached it won't noticeTakes effect on the next click
Load on your serversLowerHigher
Fits whenPermanent links, no per-click analyticsAnalytics, expiry or editable links

For this design, return 302: analytics and expiry are requirements. A 301 with an explicit Cache-Control: max-age caps how long browsers keep it, but you still lose repeat clicks inside that window. 307 and 308 are the versions of 302 and 301 that forbid changing the request method; for a click, which is a GET, they behave the same.

Step 4: data model

The access pattern decides the store. Every redirect is one lookup by code, every create is one insert, and nothing on the hot path needs a join.

FieldTypeNotes
codestring, up to 30 charactersPrimary key and shard key; 7 characters when generated
long_urlstring, up to 2,048 charactersThe destination
user_idinteger, can be emptyOwner; empty for anonymous links
created_attimestampAudits and cleanup
expires_attimestamp, can be emptyEmpty means it never expires
is_custombooleanTrue for user-chosen aliases

A small users table holds accounts, API key hashes and limits. For a “my links” page, index user_id, or in a key-value store keep a second table keyed by it. Click events go to a separate analytics store (step 7).

Step 5: how do you generate the short code?

Settle the length first. Base62 uses the digits 0–9, a–z and A–Z.

  • 62⁶ = 56,800,235,584 codes. At 100 million links a day, that lasts 568 days.
  • 62⁷ = 3,521,614,606,208, about 3.5 trillion codes. That lasts about 96 years.
  • After five years you have used 182.5 billion codes, about 5% of the 7-character space.

So use seven characters. Then pick how to make each one unique.

Option 1: hash the long URL and handle collisions

Hash the URL with MD5 or SHA-256, reduce the hash modulo 62⁷, base62-encode it and pad to seven characters.

  • Don't take the first seven characters of the base62-encoded hash. A 128-bit hash encodes to about 22 characters, and roughly 9 times in 10 the first is a digit from 1 to 7, so the codes cluster.
  • Collisions grow as the space fills: at 5% full, about 1 new code in 20 is already taken. Use a conditional insert (write only if the code is free) and, on a conflict, add an attempt number to the input and hash again.
  • Upside: no coordination between servers.
  • Downside: writes that can fail and retry, and one shared link per URL unless you add the user ID to the input.

Option 2: base62 of a counter, with a range server

Give every link a unique integer and encode it. Uniqueness comes from the counter, so there is nothing to check.

  • One global counter would be a bottleneck. Instead, a range server (or ticket server) hands each app server a block of, say, 1 million IDs, which it uses from memory.
  • With 20 app servers sharing 1,160 writes a second, each uses about 58 IDs a second, so a block lasts about 4.8 hours. The range server can be one replicated database row with an atomic increment, or a counter in ZooKeeper or etcd.
  • A crashed server wastes the rest of its block: at most 1 million IDs out of 3.5 trillion.
  • Sequential IDs make neighbouring, guessable codes. Scramble each ID with a keyed, reversible permutation over the 62⁷ range (format-preserving encryption does exactly this), then encode and pad to seven characters. (If you encode the raw counter instead, start it at 62⁶ so every code is seven characters.)

Option 3: pre-generated keys from a key generation service

A key generation service (KGS) creates random 7-character codes ahead of time. App servers fetch them in batches of, say, 1,000.

  • Batches must be claimed atomically, in a transaction that marks keys as used, or by giving each KGS replica its own slice, so no two servers get the same code.
  • One year of keys is 36.5 billion × 7 bytes ≈ 256 GB, or generate a few days ahead.
  • Upside: no collision check at write time, and random-looking codes.
  • Downside: one more stateful service to run. Keys held by a crashed server are lost, which is fine at this scale.
Hash + collision checkBase62 counter + range serverPre-generated keys (KGS)
CollisionsYes; check and retryNoneNone if claiming is atomic
CoordinationNoneOnce per blockOnce per batch
Guessable codesNoYes, unless scrambledNo
Same URL, same codeYes, by defaultNoNo
Main riskRetries grow as the space fillsRange server availabilityKey service availability
Interview verdictGood, if you explain collisionsGreat, with ranges and scramblingGreat, if you explain atomic claiming

For this design, pick the counter with a range server and a scramble, and mention the KGS as the alternative you considered. Here is the encoding in Python, with the hash variant from option 1. The outputs in the comments are from running it.

import hashlib

ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE = len(ALPHABET)  # 62
INDEX = {ch: i for i, ch in enumerate(ALPHABET)}
CODE_LEN = 7


def encode(n: int) -> str:
    """Non-negative integer ID -> base62 string."""
    if n < 0:
        raise ValueError("id must be non-negative")
    if n == 0:
        return ALPHABET[0]
    chars = []
    while n:
        n, rem = divmod(n, BASE)
        chars.append(ALPHABET[rem])
    return "".join(reversed(chars))


def decode(code: str) -> int:
    """Base62 string -> integer ID."""
    n = 0
    for ch in code:
        n = n * BASE + INDEX[ch]
    return n


def code_from_hash(long_url: str, attempt: int = 0) -> str:
    """Option 1: hash, reduce modulo 62**7, pad to 7 characters.
    If the code is taken by another URL, call again with attempt + 1."""
    digest = hashlib.sha256(f"{long_url}#{attempt}".encode()).digest()
    n = int.from_bytes(digest[:8], "big") % BASE**CODE_LEN
    return encode(n).rjust(CODE_LEN, ALPHABET[0])


print(BASE**7)                    # 3521614606208, about 3.5 trillion
print(encode(BASE**6))            # 1000000 (the smallest 7-character ID)
print(encode(BASE**7 - 1))        # ZZZZZZZ (the largest)
print(encode(125_000_000_000))    # 2crtgcg
print(decode("2crtgcg"))          # 125000000000
print(code_from_hash("https://example.com/a/very/long/path"))  # KLrgbRn

Step 6: what does the high-level design look like?

Draw two paths in separate services, because they scale differently: about 35,000 redirects a second at peak against about 3,500 creates.

  1. Write path: the client calls POST /urls through the load balancer and API gateway, which checks the API key and rate limit. The shortening service validates the URL, takes the next ID from its block, scrambles and encodes it, writes the row with a conditional insert, warms the cache and returns the short URL.
  2. Read path: the browser requests GET /{code}. The redirection service checks the cache, falls back to the database on a miss, checks expiry, returns a 302 with the Location header, and publishes a click event to a queue without waiting.
Helpwing showing a URL shortener diagram: User to Load Balancer to API Gateway; the gateway routes to a URL Shortening Service backed by a Database and to a URL Redirection Service backed by a Cache, which sends the user to the original URL.
The diagram Helpwing drew for this question (a real screenshot of the app). The gateway splits traffic between a URL shortening service that writes to the database and a URL redirection service that reads through a cache and sends the user to the original URL.

That split between writes and reads is what interviewers want to see by about the halfway mark. The deep dives add what an SDE-2 answer builds on top: the range server, the click queue, the analytics store and the rate limiter.

Step 7: deep dives

The interviewer usually picks two or three. Have a short, specific answer for each.

  • Pattern: cache-aside in Redis. Key: the code. Value: the long URL and its expiry. On a miss, read the database and set the entry.
  • TTL and eviction: about a day, never past the link's expiry, with least-recently-used eviction.
  • Warm on create: links are often clicked minutes after they are shared.
  • Negative caching: cache “not found” for a minute, so typos and scanners don't reach the database.
  • Hot keys: one viral link can overload a cache shard. Add an in-process cache on each redirect server with a TTL of a few seconds.
  • Database load: at a 90% hit rate, about 1,160 reads a second on average and 3,500 at peak.
  • Latency: a cache lookup inside a data centre takes around a millisecond, leaving most of the 50 ms for the network. For distant users, serve redirects from more than one region.

Database choice and sharding

The data is 182.5 billion small rows, read and written one key at a time. A key-value or wide-column store such as DynamoDB or Cassandra fits that shape directly. Sharded MySQL or PostgreSQL (with Vitess or Citus, for example) also works and gives you unique constraints, but you own more of the sharding. Either is fine if you give the reason.

  • Shard by a hash of the code with consistent hashing, not by code ranges. New links are the hottest, and with counter-based codes, range sharding would send them all to the newest shard.
  • Shard count: at about 2 TB per shard, 91 TB needs about 46 shards; plan for 50 to 64, each replicated.
  • Uniqueness: a conditional write: INSERT ... ON CONFLICT DO NOTHING in PostgreSQL, attribute_not_exists in DynamoDB, IF NOT EXISTS in Cassandra.
  • Replica lag: a brand-new link read from a replica can come back missing. Warming the cache on create covers most cases; check the primary before returning a 404.

Custom aliases

  • Validate: letters, digits and hyphens, 4 to 30 characters, and block reserved words such as api, admin and login.
  • Uniqueness: the same conditional insert. On a conflict, return 409.
  • Clashes with generated codes: a 7-character alias could later equal a generated code. Keep separate namespaces, for example by requiring aliases to be at least 8 characters or contain a hyphen.
  • Abuse: aliases like bank-kyc-update invite phishing. Flag sensitive words for review.

Expiry

  • Check on every redirect: the cache entry carries expires_at, and its TTL never outlives the link.
  • Status: 410 Gone, which RFC 9110 describes as suited to limited-time, promotional resources. Use 404 if you'd rather not reveal the link existed.
  • Cleanup: delete lazily with DynamoDB or Cassandra TTLs, or a nightly job that deletes in small batches.
  • Don't recycle codes: old links live on in PDFs and printed posters, and a reused code would send those readers somewhere new. With 3.5 trillion codes, you don't need to.

Analytics with an async queue

Never increment a counter in the urls table on every redirect: that is 11,600 writes a second on the hot path and a hot row for every viral link.

  • The redirect server sends the 302, then publishes an event to Kafka or Kinesis: code, time, country from the IP address, referrer, device. If the queue is slow, it drops events rather than delay redirects.
  • Consumers aggregate clicks per code per minute, hour and day into an analytics store such as ClickHouse or Druid. The stats API reads those aggregates.
  • Volume: 1 billion events a day at about 100 bytes is about 100 GB a day before compression. Keep raw events briefly, aggregates for longer.
  • Duplicates: queues usually deliver at least once, so deduplicate on an event ID or accept approximate counts. Count unique visitors with HyperLogLog.
  • Noise and privacy: chat apps fetch links to build previews, so filter known bots. Derive the country, then drop or hash the IP address.

Rate limiting and abuse

  • Create path: a token bucket per API key and per IP address at the gateway, counted in Redis, with the lowest limits for anonymous callers. Over the limit, return 429 with a Retry-After header.
  • Redirect path: limit 404s per IP address to slow down anyone scanning for codes.
  • Malicious destinations: check new URLs against a malware and phishing list such as Google Safe Browsing, re-scan old links, and disable flagged ones or show a warning page.
  • Loops and input: refuse to shorten your own short links, cap URLs at 2,048 characters, and accept only http and https.

Step 8: bottlenecks and trade-offs

Finish by naming what breaks first and what you chose to give up.

RiskWhat goes wrongMitigation
Cache cluster failsUp to 35,000 reads a second hit the databaseCache replicas, request coalescing, database sized for peak misses
One viral linkOne cache shard takes most trafficIn-process cache for a few seconds
Range server downNo new ID blocksServers hold blocks for hours; run it replicated
Replica lagA new link returns 404 brieflyWarm the cache on create; check the primary before a 404
Queue backs upAnalytics fall behindRedirects never wait on it; scale consumers
Region outageRedirects fail in that regionServe reads from several regions; give each region its own ID ranges

The trade-offs to say aloud: 302 buys accurate analytics and control at the cost of more load; the counter trades collision checks for a little coordination; one link per URL saves storage but mixes analytics and expiry across owners; and analytics are eventually consistent while links are not.

Close with a 30-second summary:

Fresher, SDE-1: closing summary

To sum up: we create about 1,200 links a second and serve about 11,600 redirects a second, so the design is built around reads. A shortening service takes ID blocks from a range server, scrambles each ID and stores a 7-character base62 code, which gives us about 3.5 trillion codes. A redirection service reads through a Redis cache to a key-value store sharded by code, returns 302 so we count every click, and sends click events to Kafka. With more time, I'd look at multi-region reads and abuse detection.

What a weak, good and strong answer covers

Most candidates draw the same boxes. The difference is what they can say about each one.

AreaWeakGoodStrong
RequirementsStarts drawing at onceFunctional listAdds latency, availability and read:write ratio; asks about aliases, expiry, analytics
EstimatesNone, or never usedRequests per second, storageAdds cache size; says which decision each number changes
Short codes“Hash the URL”, no collision planOne scheme, done correctlyCompares schemes, sizes the key space, handles guessable codes
Redirect“Return the URL”301 or 302302, with reasons tied to analytics and expiry
Read pathDatabase on every clickAdds a cacheTTL, negative caching, hot keys, cache failure
StorageA database with no reasonKeyed by codeReasons from access pattern; hash sharding; replica lag
AnalyticsCounter per clickAsync loggingQueue, aggregation, analytics store, duplicates, privacy
Failure and abuseNot mentionedRate limitsRate limits, malware checks, enumeration, regions

What is expected at SDE-1, SDE-2 and SDE-3?

At SDE-1 the interviewer helps you along. At SDE-3 they expect you to steer.

LevelYou should driveExpected depthOften probed on
Fresher or SDE-1 (0–2 years)Requirements, API, schema, a working diagram, with some promptingOne code scheme done correctly, why 7 characters, a cache“What if two servers generate the same code?”
SDE-2 (2–5 years)The whole flow unprompted, with estimates that change decisionsCode schemes compared, sharding, caching, 301 vs 302, analytics queue“The cache cluster goes down. What happens?”
SDE-3 or senior (5+ years)Which deep dives matter, and whyFailure modes, regions, replica lag, abuse, cost“How do you run this in two regions?”

URL shortener system design for freshers

For a fresher or SDE-1, the bar is a correct, complete basic design, not a lecture on distributed systems. Be able to explain without notes: why 62⁷ gives about 3.5 trillion codes, the requests-per-second arithmetic, 301 versus 302, cache-aside, and what you shard on.

If you built a URL shortener as a college project, use it: see how to explain your project in an interview. For a full plan, start with the software engineer interview preparation guide and the free picks on interview resources.

Service-to-product switcher, 3 years of Java: “which database would you use?”

Every redirect is a lookup by one key, and in five years we'll have around 180 billion rows and 90 TB, with no joins. So I'd pick a key-value store like DynamoDB or Cassandra, partitioned by a hash of the short code. We run Oracle in my current project, and sharded MySQL would work too, but then I'd own the sharding logic. Either way, a conditional insert makes sure an alias can only be claimed once.

Backend engineer, 4 years, SDE-2 loop: “what happens if the cache goes down?”

At peak the database would jump from about 3,500 reads a second to 35,000, and we'd blow the 50 ms budget. I'd defend in layers: a replica for each cache shard, a small in-process cache on each redirect server for the hottest links, and request coalescing so a thousand misses on one code become one database read. I'd also size the database for a partial cache loss and load-test it.

How Helpwing helps in this round

In system design rounds where the employer allows AI help, Helpwing hears the question and answers with the design in points plus one diagram, like the figure above. Add your resume and the job description in Tune Interview before the round so answers stay inside your own experience, and tell your interviewer you are using it. It is not for exams, online assessments, coding tests or proctored tests. See how it works or check what 15 free minutes include.

Download Helpwing

Questions people ask

Is the URL shortener asked in system design interviews for freshers?

It is a standard first design question because it fits in one round, so prepare it first if your loop has a design round. At fresher level, interviewers look for clear requirements, basic estimates, one correct code scheme and a cache.

How many characters should a short URL code be?

Seven base62 characters give about 3.5 trillion codes, enough for about 96 years at 100 million links a day. Six give 56.8 billion, which would last about a year and a half at that rate.

Why base62 and not base64?

Standard base64 includes + and /, which mean something in URLs. The URL-safe variant uses - and _, which works but is easy to mistype. Base62 keeps codes to letters and digits, and you lose little: 64⁷ is about 4.4 trillion.

Should a URL shortener return 301 or 302?

Return 302 if you need click analytics, expiry or editable links: browsers don't cache it by default, so every click reaches you. A 301 can be cached, which lowers load but hides repeat clicks.

SQL or NoSQL for a URL shortener?

Either, if you reason from the access pattern: single-key lookups, 182.5 billion rows, no joins. DynamoDB or Cassandra fits that with less work; sharded MySQL or PostgreSQL gives you unique constraints but you manage the sharding.

What happens if two people shorten the same long URL?

With the hash scheme they get the same code unless you add the user ID to the input. With a counter or pre-generated keys they get different codes, which is usually better: each owner gets their own analytics and expiry.

Sources

  1. RFC 9110: HTTP Semantics, section 15.4 (301, 302, 307, 308) and section 15.5.11 (410 Gone), IETF, checked 21 October 2026
  2. RFC 6585: Additional HTTP Status Codes, section 4 (429 Too Many Requests), IETF, checked 21 October 2026
  3. RFC 4648: The Base16, Base32, and Base64 Data Encodings, sections 4 and 5, IETF, checked 21 October 2026

Your download has started

Open Helpwing on your Mac in 3 steps. The beta is not Apple-notarized yet, so macOS flags it after a browser download. The file is fine; step 1 clears the flag.

Prefer to watch? Every step on a real Mac, from download to sign-in. 1:25
  1. When the download finishes, open Terminal (⌘ Space, type Terminal) and run:
    xattr -cr ~/Downloads/Helpwing-arm64.dmg
  2. Open the disk image and drag Helpwing to Applications.
  3. Open Helpwing from Applications and sign in with Google. When macOS asks, allow Screen & System Audio Recording, then reopen Helpwing once.

Helpwing has no Dock icon: a small dark window opens at the top of your screen, and ⌘ B shows or hides it. Still blocked? System Settings → Privacy & Security → Open Anyway. Do not turn off macOS security.

Problem? Download again · Intel Mac instead · Full troubleshooting

Installing on Windows

One warning is expected. The beta installer is not signed yet, so the first time you run it SmartScreen says “Windows protected your PC.”

Prefer to watch? Every step on a real PC, including the SmartScreen screen. 1:13
  1. Open Helpwing-Windows.exe from your Downloads folder.
  2. If you see “Windows protected your PC,” click More info, then Run anyway.
  3. When Helpwing opens, choose Sign in with Google.

Do not turn SmartScreen or antivirus off. If security software removed the file, restore it from Protection history and allow Helpwing.

Full troubleshooting · All installers and checksums

Support

Support and feedback

Tell us what broke or what you want changed. We read every message.

Or email support@helpwing.dev