Elido
7 min readEngineering

Shipping the Dub.co migration: folders flatten into tags

How we built one-click Dub.co imports for Elido — the cleanest API of the five, folder-to-tag flattening, and why the side-grade is for teams who care about EU residency.

Marius Voß
DevRel · edge infra
Pipeline diagram: Dub.co REST API on the left flowing through the Elido import worker into the links table, with a side panel listing the numeric guarantees (50k cap, 30 min budget, 100 per page, folders flatten to tags)

The fifth and final migration source in our Tier-3 rollout shipped today. Paste a Dub.co API token, optionally filter by project slug, click Start. Three to five minutes later every link sits on your Elido domain with the slug preserved and the Dub folder structure flattened into Elido tags.

This post is the engineering write-up — what's specific to Dub, why their API is the cleanest of the five vendors we support, and what motivates a Dub-to-Elido side-grade.

Why this migration exists#

Dub.co is the closest open-source equivalent to Elido in feature surface. Strong product, clean REST API, modern dashboard. The migration path here is for teams that pick Elido for one of three reasons:

  • EU residency. Elido's data plane is EU-anchored — Hetzner FRA + ASH POPs, OVH SGP for APAC, all click events land in EU-region ClickHouse by default. Dub Cloud is US-anchored; the GDPR/Schrems-II posture is the trade-off.
  • Edge POP footprint. Three regional POPs with p95 < 15ms cache HIT is a different latency target than Dub's Cloudflare-Workers-only path. Latency-sensitive workloads (mobile-first, ad-attribution) feel the difference.
  • Analytics depth. ClickHouse-backed click analytics with per-event retention, conversion forwarding to GA4/Meta CAPI, and full historical replay. Dub's analytics are clean but PostgreSQL-aggregated; the depth ceiling is different.

If none of those apply, Dub is a fine product. The migration is here for the teams it does apply to.

Why Dub's API is the cleanest#

We've now built workers against five vendor APIs. Ranking by ease-of-integration:

  1. Dub.co — bearer token, JSON-RFC-compliant errors, ?page= + ?limit=100 pagination, every field documented with example payloads.
  2. Short.io — clean, HasMore boolean explicit, but per-domain partitioning needs UX work.
  3. Bitlypagination.next URL is standards-compliant; the surrounding API reference is thorough.
  4. TinyURL — Pro/Bulk only, the rest is unsupported; documentation is sparse.
  5. Rebrandly?last=<id> cursor is fine but the 25-per-page cap makes everything feel slow.

Dub's edge: their docs include curl examples, their error responses include both a machine code and a human message, and their pagination is the boring kind where ?page=2&limit=100 works exactly the way you'd guess.

const dubPageSize = 100

page := 1
for {
    resp, err := w.fetchPage(ctx, opts.Token, opts.WorkspaceID, page)
    if err != nil { /* mark failed */ return }
    if len(resp) == 0 { break }
    for _, link := range resp { /* import */ }
    if len(resp) < dubPageSize { break }
    page++
}

Dub doesn't return a HasMore flag; we infer it from a short page. This is the standard REST pagination pattern and it works fine — a page shorter than the limit means we're done.

Folders flatten into tags#

Dub has both folders and tags as organising primitives. Elido has only tags. So the migration flattens Dub folders into the tag bag:

tags := make([]string, 0, len(link.Tags)+2)
for _, t := range link.Tags {
    tags = append(tags, t.Name)
}
if link.Folder != nil && link.Folder.Name != "" {
    // Dub folders can nest; flatten the full path.
    for _, segment := range strings.Split(link.Folder.Name, "/") {
        seg := strings.TrimSpace(segment)
        if seg != "" {
            tags = append(tags, seg)
        }
    }
}
tags = append(tags, "imported:dub")

A Dub link in folder campaigns/q3-launch with tags paid and linkedin imports with tags paid, linkedin, campaigns, q3-launch, and imported:dub. The filter semantics in Elido handle the same retrieval patterns as Dub's folder UI — tag-equals, tag-contains, multi-tag-AND. We aren't reinventing the folder hierarchy server-side; the user gets a flat list of tags and the filter primitives.

Could we have added folders to Elido instead? Yes. We chose tags-only when Elido's data model shipped in Phase 1; folders make sense for desktop-file-system mental models and less sense for short-link bulk operations. Migrating Dub users into Elido tags is the right side of that trade.

Project filtering#

Dub uses "workspaces" (in their newer UI) and historically called them "projects". The API accepts a workspaceId parameter to filter; the launcher exposes it as an optional text field. Paste the workspace slug from your Dub URL, or leave blank to grab every link the token can see.

This mirrors the Rebrandly workspace filter and the Short.io domain field. Three of our five migration vendors have a per-account partitioning concept; we expose it consistently as an optional text input rather than a populated dropdown because the typical user has at most two workspaces and the API listing endpoint adds latency that's not worth the polish.

What we don't migrate#

Dub's geo-targeting and device-targeting rules. They're a powerful Dub feature but the rule shape doesn't map 1:1 to Elido smart-link rules. The slug imports; rebuild the rules using Elido's expression syntax, which is more permissive but takes a different mental shape.

Per-click history. Universal limit across all five migration sources. Dub's per-click data sits behind their analytics endpoint, which is plan-tier-bound; new clicks land in Elido analytics from cutover.

QR styling. Default Elido QR is regenerated; custom designs need to be re-applied. The imported:dub tag is the bulk-reassignment handle.

Dub workspace ACLs and role configuration. Re-grant access in Elido using SCIM/SSO or workspace member invites; the role model differs enough between the two products that automated mapping would surface as silent privilege escalation.

Self-hosted Dub#

Dub is open-source and self-hosted Dub instances are common. The migration uses the same REST API the Dub Cloud product exposes, so pointing at a self-hosted Dub means overriding DUB_API_BASE. We didn't expose that as a self-serve setting in v1 because the operational tail is non-trivial — different Dub versions expose subtly different response shapes, and we don't want to ship a launcher that silently 500s on a Dub v0.7 deployment when v0.9 is the tested target.

For self-hosted Dub migrations, email [email protected] with your Dub version and we'll run a one-off concierge migration. Once we've seen enough versions in the wild, the override becomes a self-serve dashboard setting.

Token handling#

Same one-shot semantics as the four other migration vendors:

bgCtx := context.WithoutCancel(r.Context())
go h.dub.Run(bgCtx, job.ID, imports.DubJobOptions{
    Token:       req.Token,
    WorkspaceID: req.WorkspaceID,
})

source_token_id stays NULL. Token lives in api-core process memory for the worker run, dropped at completion. No persistence — this is a one-shot migration, not a recurring vendor call.

context.WithoutCancel (Go 1.21+) keeps the worker alive past the HTTP request. Same pattern as every other migration vendor in this rollout.

Conflict resolution and the worker contract#

Identical to every other vendor. Suffix walks mylink-2, mylink-3, …, up to 50 candidates; skip leaves the existing Elido link alone; fail aborts on first conflict. Worker contract — MaxLinksPerImport=50_000, ImportRunBudget=30*time.Minute — is shared across all five.

The lookup is one indexed read per row on (domain_id, slug). Deterministic suffix walks, friendlier errors than fishing for uniqueness violations in pgx.

What we measured against Bitly#

Both Dub and Bitly migrate at roughly the same throughput — 100 links per page, ~5 inserts/sec sustained. A 5,000-link source finishes in 4–7 minutes for both vendors. The user-visible difference is the post-import experience: Dub-imported links arrive with structured folder-as-tag breadcrumbs; Bitly-imported links arrive with just the imported:bitly tag and any free-form Bitly tags.

Resumability and the deploy problem#

Same trade as the first four migrations. Worker is in-process; a mid-import deploy kills the goroutine. The 5-minute stuck-sweep cron flips any running row with no progress in 30 minutes to failed. Re-running is idempotent under suffix and skip strategies.

For accounts above 10,000 links, resumability would earn its keep — we'd record the Dub page cursor in import_jobs.source_filter and resume from the last completed page. All five migration vendors share the same in-process design; when we ship resumability, all five benefit.

What's next for the Tier-3 rollout#

Tier-3 is done. Five migration vendors, one shared import_jobs table, one shared worker contract, one shared dashboard polling UI, five SEO landings, five engineering blog posts.

What's queued behind Tier-3:

  • Resumability for accounts north of 10,000 links. Per-vendor cursor checkpointing.
  • CSV-export fallback for users on plans with revoked tokens. Currently concierge-only.
  • Tier-2 service_tokens foundation — recurring-use vendor tokens for Mailchimp, Brevo, Klaviyo. The migration path validated the JSONB source_filter pattern; Tier-2 needs persistent encrypted tokens, which is ADR-0036 territory.

If you've been side-eyeing Elido from a Dub workspace, the migration story is documented now. Try it — token to last imported link in under five minutes for typical accounts.

Try Elido

EU-hosted URL shortener with custom domains, deep analytics, and an open API. Free tier — no credit card.

Tags
dub.co migration
url shortener
go worker
data migration
engineering
tier 3 integrations

Continue reading