{"id":1848,"date":"2026-07-25T17:00:00","date_gmt":"2026-07-25T22:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=1848"},"modified":"2026-03-07T03:50:12","modified_gmt":"2026-03-07T08:50:12","slug":"deep-linking-lifecycle","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/deep-linking-lifecycle\/","title":{"rendered":"The Deep Linking Lifecycle: From Click to Content"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A deep link click triggers a chain of events across the browser, operating system, app store, and your app. Each step can succeed or fail, and failures at different stages produce different user experiences. Understanding the full lifecycle helps you debug issues and optimize each transition.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the technical overview, see <a href=\"https:\/\/tolinku.com\/blog\/how-deep-linking-works\/\">how deep linking works: a technical overview<\/a>. For routing specifics, see <a href=\"https:\/\/tolinku.com\/blog\/deep-link-routing-guide\/\">deep link routing: how to route users to the right screen<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Full Lifecycle<\/h2>\n\n\n\n<pre><code>1. User taps a link\n   \u2193\n2. Browser\/OS intercepts the URL\n   \u2193\n3. OS checks domain verification (AASA \/ assetlinks.json)\n   \u251c\u2500\u2500 Verified \u2192 4a. OS launches the app\n   \u2514\u2500\u2500 Not verified \u2192 4b. Browser loads the URL\n   \u2193\n4a. App receives the URL\n   \u2193\n5. App routes to the correct screen\n   \u2193\n6. Screen loads with the correct content\n   \u2193\n7. Analytics event fires\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Each stage has its own failure modes, timing constraints, and optimization opportunities.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Stage 1: The Click<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Where the Click Happens<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The user&#39;s context when they tap the link determines what happens next:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table>\n<thead>\n<tr>\n<th>Context<\/th>\n<th>What Happens<\/th>\n<\/tr>\n<\/thead>\n<tbody><tr>\n<td>System browser (Safari, Chrome)<\/td>\n<td>Universal Links \/ App Links activate normally<\/td>\n<\/tr>\n<tr>\n<td>In-app browser (Facebook, Instagram)<\/td>\n<td>Universal Links are blocked; App Links may work<\/td>\n<\/tr>\n<tr>\n<td>Email client (Gmail app)<\/td>\n<td>Often opens in-app browser first<\/td>\n<\/tr>\n<tr>\n<td>Push notification<\/td>\n<td>App is already the handler; no browser involved<\/td>\n<\/tr>\n<tr>\n<td>QR code scan<\/td>\n<td>Camera app opens system browser<\/td>\n<\/tr>\n<tr>\n<td>SMS\/iMessage<\/td>\n<td>Links open in system browser<\/td>\n<\/tr>\n<tr>\n<td>Desktop browser<\/td>\n<td>No app to open; web fallback<\/td>\n<\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Click Recording<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you are using a deep link platform, the click is recorded server-side before any redirection:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">async function handleClick(req: Request): Promise&lt;Response&gt; {\n  const clickId = generateClickId();\n  const startTime = Date.now();\n\n  \/\/ Record the click\n  await analytics.recordClick({\n    clickId,\n    timestamp: new Date(),\n    url: req.url,\n    ip: req.ip,\n    userAgent: req.headers[&#39;user-agent&#39;],\n    referrer: req.headers[&#39;referer&#39;],\n    route: parseRoute(req.url)\n  });\n\n  \/\/ Determine the response (redirect, render, etc.)\n  const response = await resolveLink(req);\n\n  \/\/ Record latency\n  await analytics.updateClick(clickId, {\n    latencyMs: Date.now() - startTime,\n    outcome: response.type \/\/ &#39;app_opened&#39;, &#39;fallback&#39;, &#39;store_redirect&#39;\n  });\n\n  return response;\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Stage 2: OS Interception<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">iOS (Universal Links)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When the user taps an HTTPS link:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>iOS checks its local cache of AASA files.<\/li>\n<li>If the domain is in the cache and the URL path matches an <code>applinks<\/code> entry, iOS opens the registered app.<\/li>\n<li>If the domain is not cached, iOS fetches the AASA file from Apple&#39;s CDN (not directly from your server on iOS 14+).<\/li>\n<li>If the AASA is invalid, missing, or the path does not match, Safari opens the URL normally.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Timing:<\/strong> The AASA lookup is nearly instant (cached locally). The initial download from Apple&#39;s CDN happens during app installation, not at click time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Key detail:<\/strong> If the user long-presses a Universal Link and sees &quot;Open in Safari&quot; and &quot;Open in [App],&quot; they can choose to open in Safari. Once they do this, iOS remembers the preference and subsequent taps on that domain open in Safari. The user must long-press again and choose &quot;Open in [App]&quot; to restore the behavior.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Android (App Links)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When the user taps an HTTPS link:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Android checks if any installed app has a verified App Link for this domain.<\/li>\n<li>If verified (<code>autoVerify=&quot;true&quot;<\/code> in the manifest and <code>assetlinks.json<\/code> validates), the app opens automatically.<\/li>\n<li>If not verified, Android shows a disambiguation dialog (&quot;Open with: Chrome or [App]&quot;).<\/li>\n<li>If no app handles the URL, Chrome opens the URL normally.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Timing:<\/strong> Verification happens at install time and periodically (Android 12+). The click-time check is instant.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Stage 3: Verification<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">AASA Verification (iOS)<\/h3>\n\n\n\n<pre><code>App installed\n  \u2193\niOS fetches AASA from Apple&#39;s CDN\n  \u2193\nCDN checks your server: GET \/.well-known\/apple-app-site-association\n  \u2193\nServer returns JSON with applinks\n  \u2193\niOS caches the AASA locally\n  \u2193\nAll future link taps check the local cache\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If the AASA is not available when the app is installed, Universal Links will not work until the cache is refreshed (up to 24 hours or on app reinstall).<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">assetlinks.json Verification (Android)<\/h3>\n\n\n\n<pre><code>App installed\n  \u2193\nAndroid fetches \/.well-known\/assetlinks.json from your server\n  \u2193\nVerifies: package name matches, SHA-256 fingerprint matches\n  \u2193\nMarks the domain as &quot;verified&quot; for this app\n  \u2193\nAll future link taps open the app directly (no dialog)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">On Android 12+, re-verification happens periodically. If <code>assetlinks.json<\/code> is unavailable during re-verification, the domain may become &quot;unverified.&quot;<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Stage 4: App Launch or Fallback<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">App Opens (Happy Path)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The OS launches the app and passes the URL:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>iOS:<\/strong><\/p>\n\n\n\n<pre><code class=\"language-swift\">\/\/ Scene delegate (iOS 13+)\nfunc scene(_ scene: UIScene, continue userActivity: NSUserActivity) {\n    guard let url = userActivity.webpageURL else { return }\n    handleDeepLink(url)\n}\n\n\/\/ SwiftUI\n.onOpenURL { url in\n    handleDeepLink(url)\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Android:<\/strong><\/p>\n\n\n\n<pre><code class=\"language-kotlin\">override fun onCreate(savedInstanceState: Bundle?) {\n    super.onCreate(savedInstanceState)\n    val url = intent.data\n    if (url != null) {\n        handleDeepLink(url)\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Fallback (App Not Installed or Verification Failed)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If the app does not open, the browser loads the URL. Your server should detect this and serve an appropriate fallback:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">app.get(&#39;\/products\/:id&#39;, (req, res) =&gt; {\n  const userAgent = req.headers[&#39;user-agent&#39;] || &#39;&#39;;\n\n  if (isIOS(userAgent)) {\n    \/\/ Show web content + smart banner to install the app\n    res.render(&#39;product-fallback&#39;, {\n      product: getProduct(req.params.id),\n      appStoreUrl: &#39;https:\/\/apps.apple.com\/app\/id123456&#39;,\n      deepLink: req.url\n    });\n  } else if (isAndroid(userAgent)) {\n    \/\/ Same, with Play Store link\n    res.render(&#39;product-fallback&#39;, {\n      product: getProduct(req.params.id),\n      playStoreUrl: &#39;https:\/\/play.google.com\/store\/apps\/details?id=com.example.app&#39;,\n      deepLink: req.url\n    });\n  } else {\n    \/\/ Desktop: full web experience\n    res.render(&#39;product&#39;, { product: getProduct(req.params.id) });\n  }\n});\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Stage 5: Routing<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Once the app receives the URL, it must route to the correct screen:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">function routeDeepLink(url: URL): Screen {\n  const path = url.pathname;\n  const params = Object.fromEntries(url.searchParams);\n\n  \/\/ Match routes in order of specificity\n  const routes = [\n    { pattern: \/^\\\/products\\\/([^\/]+)$\/, screen: &#39;ProductDetail&#39;, extract: [&#39;productId&#39;] },\n    { pattern: \/^\\\/offers\\\/([^\/]+)$\/, screen: &#39;OfferDetail&#39;, extract: [&#39;offerId&#39;] },\n    { pattern: \/^\\\/users\\\/([^\/]+)\\\/posts\\\/([^\/]+)$\/, screen: &#39;Post&#39;, extract: [&#39;userId&#39;, &#39;postId&#39;] },\n    { pattern: \/^\\\/referral\\\/([^\/]+)$\/, screen: &#39;Referral&#39;, extract: [&#39;referrerId&#39;] },\n    { pattern: \/^\\\/settings$\/, screen: &#39;Settings&#39;, extract: [] },\n  ];\n\n  for (const route of routes) {\n    const match = path.match(route.pattern);\n    if (match) {\n      const pathParams: Record&lt;string, string&gt; = {};\n      route.extract.forEach((name, i) =&gt; {\n        pathParams[name] = match[i + 1];\n      });\n      return { name: route.screen, pathParams, queryParams: params };\n    }\n  }\n\n  \/\/ No match: default to home screen\n  return { name: &#39;Home&#39;, pathParams: {}, queryParams: params };\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Authentication-Gated Routes<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Some deep link routes require the user to be logged in:<\/p>\n\n\n\n<pre><code class=\"language-swift\">func handleDeepLink(_ url: URL) {\n    let route = routeDeepLink(url)\n\n    if route.requiresAuth &amp;&amp; !user.isLoggedIn {\n        \/\/ Save the deep link for after login\n        pendingDeepLink = url\n        showLoginScreen()\n    } else {\n        navigateTo(route)\n    }\n}\n\nfunc onLoginSuccess() {\n    if let pending = pendingDeepLink {\n        handleDeepLink(pending)\n        pendingDeepLink = nil\n    }\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Stage 6: Content Loading<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The screen needs to load the correct content based on the deep link parameters:<\/p>\n\n\n\n<pre><code class=\"language-swift\">class ProductViewController: UIViewController {\n    let productId: String\n\n    init(productId: String) {\n        self.productId = productId\n        super.init(nibName: nil, bundle: nil)\n    }\n\n    override func viewDidLoad() {\n        super.viewDidLoad()\n        loadProduct()\n    }\n\n    func loadProduct() {\n        showLoadingSpinner()\n\n        api.getProduct(id: productId) { [weak self] result in\n            switch result {\n            case .success(let product):\n                self?.displayProduct(product)\n            case .failure:\n                self?.showError(&quot;Product not found&quot;)\n            }\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Handle edge cases:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Content deleted:<\/strong> Show a friendly message, not a crash.<\/li>\n<li><strong>Content restricted:<\/strong> Show login or permission request.<\/li>\n<li><strong>Slow network:<\/strong> Show a loading state, not a blank screen.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Stage 7: Analytics<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">After the content loads, fire an analytics event:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">function trackDeepLinkSuccess(deepLink: DeepLinkData) {\n  analytics.track(&#39;deep_link_opened&#39;, {\n    url: deepLink.url,\n    route: deepLink.route,\n    source: deepLink.queryParams.utm_source,\n    campaign: deepLink.queryParams.utm_campaign,\n    screen: deepLink.screen,\n    loadTimeMs: deepLink.loadTime,\n    isDeferred: deepLink.isDeferred\n  });\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">The Deferred Lifecycle<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">For users who do not have the app installed, the lifecycle is longer:<\/p>\n\n\n\n<pre><code>1. User taps link\n2. OS cannot open app (not installed)\n3. Browser loads fallback page\n4. User taps &quot;Get the App&quot; \u2192 App Store\n5. User installs the app\n6. User opens the app for the first time\n7. App checks for deferred deep link match\n8. Match found \u2192 Navigate to original content\n9. Analytics: deferred deep link success\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The critical step is #7: matching the original click to the first app open. This is covered in detail in <a href=\"https:\/\/tolinku.com\/blog\/how-deep-linking-works\/\">how deep linking works: a technical overview<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Tolinku for the Deep Linking Lifecycle<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/tolinku.com\/features\/deep-linking\">Tolinku<\/a> handles the entire lifecycle: click recording, AASA\/assetlinks.json hosting, fallback pages, deferred deep link matching, and analytics. See the <a href=\"https:\/\/tolinku.com\/docs\/concepts\/deep-linking\/\">deep linking concepts<\/a> for how each stage is managed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the technical details, see <a href=\"https:\/\/tolinku.com\/blog\/how-deep-linking-works\/\">how deep linking works: a technical overview<\/a>. For the complete guide, see <a href=\"https:\/\/tolinku.com\/blog\/complete-guide-deep-linking-2026\/\">the complete guide to deep linking in 2026<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Follow a deep link from click to in-app content. Understand every step of the deep linking lifecycle including resolution, routing, and fallback.<\/p>\n","protected":false},"author":2,"featured_media":1847,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"The Deep Linking Lifecycle: From Click to Content","rank_math_description":"Follow a deep link from click to in-app content. Understand every step of the deep linking lifecycle including resolution, routing, and fallback.","rank_math_focus_keyword":"deep linking lifecycle","rank_math_canonical_url":"","rank_math_facebook_title":"","rank_math_facebook_description":"","rank_math_facebook_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-deep-linking-lifecycle.png","rank_math_facebook_image_id":"","rank_math_twitter_title":"","rank_math_twitter_description":"","rank_math_twitter_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-deep-linking-lifecycle.png","footnotes":""},"categories":[11],"tags":[25,23,305,20,24,69,183,22],"class_list":["post-1848","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-deep-linking","tag-android","tag-app-links","tag-architecture","tag-deep-linking","tag-ios","tag-mobile-development","tag-routing","tag-universal-links"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1848","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/comments?post=1848"}],"version-history":[{"count":1,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1848\/revisions"}],"predecessor-version":[{"id":1849,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1848\/revisions\/1849"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/1847"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=1848"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=1848"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=1848"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}