Skip to content

HATEOAS Link Discovery — How Clients Find and Follow Links at Runtime

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about HATEOAS Link Discovery. We cover key concepts, practical examples, and best practices to help you master this topic.

HATEOAS link discovery is the process by which clients find relevant URLs at runtime by examining link relations in API responses, eliminating the need for hardcoded URLs and enabling runtime API navigation.

What You'll Learn

  • Link discovery strategies for clients
  • IANA and custom link relations
  • Curie-based compact link URIs
  • Following links programmatically
  • Caching discovered URLs
  • Handling missing or changed links

Why It Matters

The core promise of HATEOAS is that clients don't need to know URLs. Instead, they discover them by following links. This decouples clients from server URL structures, allowing the server to reorganize URLs without breaking clients. DodaTech's Durga Antivirus Pro clients discover all API endpoints at startup by following links from a single root URL.

Real-World Use

A monitoring client starts by fetching GET /api. The response contains links to /devices, /threats, /scans, and /reports. The client caches these URLs. When the server updates its URL structure in a new version, the root URL's links change, and the client adapts automatically.

sequenceDiagram
    participant Client
    participant API
    Client->>API: GET /api (root)
    API-->>Client: _links: { devices, threats, scans }
    Client->>Client: Cache discovered URLs
    Client->>API: Follow /devices
    API-->>Client: _links: { self, threats, quarantine }
    Client->>API: Follow /devices/1/threats
    API-->>Client: _links: { self, next, analyze }
    Client->>Client: Navigate by following links
    Note over Client,API: No hardcoded URLs anywhere

Code Examples

class HypermediaClient {
  constructor(rootUrl) {
    this.rootUrl = rootUrl;
    this.linkCache = new Map();
    this.followHistory = [];
  }
  
  // Discover links from a response
  discoverLinks(response) {
    const links = {};
    
    // HAL-style _links
    if (response._links) {
      for (const [rel, link] of Object.entries(response._links)) {
        if (Array.isArray(link)) {
          links[rel] = link.map(l => l.href);
        } else if (link.href) {
          links[rel] = link.href;
        }
      }
    }
    
    // Siren-style links array
    if (response.links) {
      for (const link of response.links) {
        const rel = link.rel[0] || link.rel;
        links[rel] = link.href;
      }
    }
    
    // Siren-style actions
    if (response.actions) {
      for (const action of response.actions) {
        links[`action:${action.name}`] = {
          href: action.href,
          method: action.method,
          fields: action.fields,
        };
      }
    }
    
    return links;
  }
  
  // Follow a link by relation
  async follow(rel, options = {}) {
    // Check cache first for rel->url mapping
    let url = this.linkCache.get(rel);
    
    if (!url) {
      // Need to discover this link from current resource
      const current = await this.getCurrentResource();
      const links = this.discoverLinks(current);
      url = links[rel];
      
      if (!url) {
        throw new Error(`Link '${rel}' not found in current resource`);
      }
      
      // Cache the discovered URL
      if (typeof url === 'string') {
        this.linkCache.set(rel, url);
      }
    }
    
    // Obtain the URL
    const href = typeof url === 'string' ? url : url.href;
    
    this.followHistory.push({ rel, href, timestamp: Date.now() });
    
    // Determine HTTP method
    const method = (url && url.method) || 'GET';
    
    const fetchOptions = {
      method,
      headers: { 'Accept': 'application/json' },
    };
    
    if (options.data && method !== 'GET') {
      fetchOptions.body = JSON.stringify(options.data);
      fetchOptions.headers['Content-Type'] = 'application/json';
    }
    
    const res = await fetch(href, fetchOptions);
    const data = await res.json();
    
    // Auto-discover links from response
    const newLinks = this.discoverLinks(data);
    for (const [newRel, newUrl] of Object.entries(newLinks)) {
      this.linkCache.set(newRel, newUrl);
    }
    
    return data;
  }
  
  // Start from root and navigate
  async start() {
    console.log('Starting from root:', this.rootUrl);
    const root = await this.follow(this.rootUrl);
    
    console.log('Discovered links:', 
      Array.from(this.linkCache.keys()));
    
    // Follow links to explore the API
    if (this.linkCache.has('devices')) {
      const devices = await this.follow('devices');
      console.log('Devices retrieved');
    }
    
    return root;
  }
}

// Usage
const client = new HypermediaClient('https://api.dodatech.com');
await client.start();
// Client now has all URLs cached by relation
const threats = await client.follow('threats');
// IANA standard link relations
const IANA_RELS = {
  self: 'The resource itself',
  next: 'Next page of results',
  prev: 'Previous page of results',
  first: 'First page of results',
  last: 'Last page of results',
  collection: 'Collection of resources',
  item: 'Item within a collection',
  edit: 'Edit this resource',
  delete: 'Delete this resource',
  describedBy: 'Schema or documentation',
  payment: 'Payment endpoint',
  license: 'License information',
};

// Custom link relations with CURIE format
const CUSTOM_RELS = {
  'dt:quarantine': 'Quarantine a device',
  'dt:analyze': 'Run threat analysis',
  'dt:scan': 'Initiate a scan',
  'dt:reports': 'Access reports',
  'dt:config': 'Device configuration',
};

// Link discovery with relation validation
function validateLink(rel, links) {
  // IANA relations don't need prefix
  if (IANA_RELS[rel]) return true;
  
  // Custom relations should use CURIE
  if (rel.includes(':')) {
    const [prefix, name] = rel.split(':');
    const curie = links._links?.curies?.find(c => c.name === prefix);
    if (curie) return true;
    console.warn(`Custom relation '${rel}' found but no CURIE documentation`);
  }
  
  return false;
}

// Client that handles both standard and custom links
async function discoverAndValidate(apiResponse) {
  const links = apiResponse._links || apiResponse.links;
  if (!links) return;
  
  const discovered = [];
  
  for (const [rel, value] of Object.entries(links)) {
    if (rel === 'curies') continue;
    
    const href = Array.isArray(value) ? value[0]?.href : value?.href;
    const isStandard = IANA_RELS[rel] !== undefined;
    
    discovered.push({
      rel,
      href,
      type: isStandard ? 'IANA' : 'custom',
      description: IANA_RELS[rel] || 
        (CUSTOM_RELS[rel] || 'Undocumented custom relation'),
    });
  }
  
  return discovered;
}
class LinkCache {
  constructor(ttlMs = 300000) { // 5 minute default TTL
    this.cache = new Map();
    this.defaultTtl = ttlMs;
  }
  
  set(rel, url, ttlMs) {
    this.cache.set(rel, {
      url,
      expiresAt: Date.now() + (ttlMs || this.defaultTtl),
    });
  }
  
  get(rel) {
    const entry = this.cache.get(rel);
    if (!entry) return null;
    
    if (Date.now() > entry.expiresAt) {
      this.cache.delete(rel);
      return null; // Expired, need re-discovery
    }
    
    return entry.url;
  }
  
  invalidate(rel) {
    this.cache.delete(rel);
  }
  
  invalidateAll() {
    this.cache.clear();
  }
}

class ResilientClient {
  constructor(rootUrl) {
    this.rootUrl = rootUrl;
    this.linkCache = new LinkCache();
    this.currentResource = null;
    this.currentUrl = rootUrl;
    this.retries = 3;
  }
  
  async follow(rel) {
    let url = this.linkCache.get(rel);
    
    // If not cached, try to discover from current resource
    if (!url && this.currentResource) {
      const links = this.discover(this.currentResource);
      if (links[rel]) {
        url = links[rel];
        this.linkCache.set(rel, url);
      }
    }
    
    // If still not found, go back to root and re-discover
    if (!url) {
      console.log(`Link '${rel}' not in cache, rediscovering from root`);
      await this.fetchAndCache(this.rootUrl);
      const links = this.discover(this.currentResource);
      url = links[rel];
      if (!url) {
        throw new Error(`Link '${rel}' not found even after rediscovery`);
      }
    }
    
    return this.fetchWithRetry(url, this.retries);
  }
  
  async fetchAndCache(url) {
    const res = await fetch(url);
    const data = await res.json();
    this.currentResource = data;
    this.currentUrl = url;
    
    const links = this.discover(data);
    for (const [rel, href] of Object.entries(links)) {
      this.linkCache.set(rel, href);
    }
    
    return data;
  }
  
  async fetchWithRetry(url, retriesLeft) {
    try {
      return await this.fetchAndCache(url);
    } catch (error) {
      if (retriesLeft > 0 && this.isRetryable(error)) {
        // Link may have changed, invalidate cache
        this.linkCache.invalidateAll();
        return this.fetchWithRetry(url, retriesLeft - 1);
      }
      throw error;
    }
  }
}

Common Mistakes

  1. Hardcoding URLs despite having hypermedia — some developers add _links but still hardcode URLs in client code. Always follow links instead.
  2. Not caching discovered URLs — discovering links on every request adds latency. Cache discovered URLs with a reasonable TTL.
  3. Ignoring link relations that change — when the server renames a relation, cached URLs point to dead ends. Handle 404s by re-discovering from root.
  4. Using only one link discovery Strategy — support both HAL _links and Siren links/actions. Different API styles use different formats.
  5. Not discovering links from embedded resources — embedded sub-entities have their own links. Discover and cache them too.

Practice Questions

  1. How does a HATEOAS client discover links at runtime?
  2. What is the difference between IANA and custom link relations?
  3. Why should clients cache discovered URLs?
  4. How does a client handle a link that no longer exists?
  5. What is the role of CURIE in link discovery?

Challenge: Build a HATEOAS client that discovers all available endpoints from a single root URL, caches them with TTL, follows pagination links automatically, handles missing links by re-discovering from root, and supports both HAL and Siren formats.

Mini Project

Build a generic HATEOAS discovery client with: root URL entry point, automatic link discovery from HAL/Siren responses, link caching with configurable TTL, automatic pagination via next links, fallback to root re-discovery on broken links, and a CLI interface that lets users navigate by link relation names.

FAQ

What link relations should every API include?

At minimum: self (current resource), collection (parent collection). For listings: next, prev, first, last. For CRUD: edit, delete. For docs: describedBy.

Should I use IANA or custom link relations?

Use IANA relations for standard operations (self, next, edit). Use custom relations with CURIE prefix for domain-specific operations (dt:quarantine, dt:analyze).

How often should clients re-discover links?

Cache for 5-15 minutes by default. Re-discover on 404 errors. Force re-discovery every hour for long-running clients.

What happens when a server removes a link relation?

Clients that try to follow it get a 404. The client should re-discover from the root URL and update its cache. If the relation no longer exists, the capability was intentionally removed.

How do I discover link relations at development time?

Use curl or Postman to explore the API and inspect _links in responses. For documentation, serve a /rels endpoint that describes all available link relations.

What's Next

Learn about HATEOAS links and relations

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro