You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Relay

Global Object Identification

Working with Relay's global ID system and node interface

Global Object Identification

Master Relay's Global Object Identification system with free flashcards and spaced repetition practice. This lesson covers globally unique IDs, node identification patterns, and ID encoding strategiesβ€”essential concepts for building scalable GraphQL APIs with normalized caching.

Welcome to Global Object Identification 🌐

In Relay, every object needs a way to be uniquely identified across your entire application. Imagine trying to find a specific book in a library without a catalog numberβ€”chaos! Global Object Identification solves this problem by giving each entity in your graph a unique, globally resolvable ID. This seemingly simple concept unlocks powerful features: efficient caching, automatic refetching, optimistic updates, and seamless data normalization.

Think of global IDs as passports for your data objects. Just as a passport uniquely identifies a person across all countries, a global ID uniquely identifies an object across all queries, mutations, and even different parts of your client application. This lesson will show you how to implement, encode, and leverage these IDs for maximum performance and developer experience.

Core Concepts πŸ’‘

Why Global IDs Matter

Traditional REST APIs often use context-dependent IDs. A user might have ID 42 in the /users endpoint and the same 42 appears in /posts for a completely different post. This creates ambiguity:

REST API Problem:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  GET /users/42      β”‚    β”‚  GET /posts/42      β”‚
β”‚  β†’ User object      β”‚    β”‚  β†’ Post object      β”‚
β”‚  { id: 42, ... }    β”‚    β”‚  { id: 42, ... }    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         ❌ Same ID, different objects!

Relay solves this with globally unique identifiers:

Relay Global ID Solution:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  node(id: "VXNlcjo0Mg==")       β”‚
β”‚  β†’ User object (ID 42)          β”‚
β”‚                                 β”‚
β”‚  node(id: "UG9zdDo0Mg==")       β”‚
β”‚  β†’ Post object (ID 42)          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         βœ… Different encoded IDs!

The Node Interface πŸ”Œ

The foundation of Relay's identification system is the Node interface. Any type that implements Node must provide:

  1. An id field (non-null ID type) that's globally unique
  2. The ability to be refetched using just that ID
interface Node {
  id: ID!
}

type User implements Node {
  id: ID!
  name: String!
  email: String!
}

type Post implements Node {
  id: ID!
  title: String!
  content: String!
  author: User!
}

Every object implementing Node can be fetched through the node root query field:

type Query {
  node(id: ID!): Node
}

This single entry point lets you retrieve any object in your entire graph:

query {
  node(id: "VXNlcjo0Mg==") {
    id
    ... on User {
      name
      email
    }
  }
}

πŸ’‘ Why this matters: Relay's cache can now store objects by their global ID. When you fetch a User in one query and encounter the same User elsewhere, Relay recognizes it's the same object and updates the cache automaticallyβ€”no duplicate data!

ID Encoding Strategy πŸ”

Relay convention uses Base64 encoding to create opaque, globally unique IDs. The typical pattern:

Original format: "TypeName:LocalID"
Encoded format: Base64("TypeName:LocalID")

Example encoding process:

StepValueDescription
1User, 42Type name and local database ID
2"User:42"Concatenate with colon separator
3"VXNlcjo0Mg=="Base64 encode the string

Decoding process:

StepValueDescription
1"VXNlcjo0Mg=="Received global ID
2"User:42"Base64 decode
3Type="User", ID=42Split on colon

⚠️ Important: Global IDs should be treated as opaque identifiers by clients. Never decode them in client code or rely on their internal structureβ€”the server owns the encoding format and can change it.

Benefits of Base64 Encoding

  1. URL-safe: Can be used in query parameters without escaping
  2. Opaque: Hides internal database structure from clients
  3. Type information: Includes typename for server-side routing
  4. Version-proof: Server can change encoding without breaking clients

Implementation Pattern πŸ› οΈ

Here's how to implement global IDs in a typical server:

Step 1: Create encoding/decoding utilities

// toGlobalId: Creates a global ID from type and local ID
function toGlobalId(type, id) {
  const str = `${type}:${id}`;
  return Buffer.from(str).toString('base64');
}

// fromGlobalId: Extracts type and local ID from global ID
function fromGlobalId(globalId) {
  const str = Buffer.from(globalId, 'base64').toString('utf-8');
  const [type, id] = str.split(':');
  return { type, id };
}

// Examples:
toGlobalId('User', '42')      // β†’ "VXNlcjo0Mg=="
toGlobalId('Post', '123')     // β†’ "UG9zdDoxMjM="
fromGlobalId('VXNlcjo0Mg==') // β†’ { type: 'User', id: '42' }

Step 2: Implement the Node interface

const nodeDefinitions = {
  // nodeInterface: The GraphQL interface
  nodeInterface: new GraphQLInterfaceType({
    name: 'Node',
    fields: {
      id: { type: new GraphQLNonNull(GraphQLID) }
    }
  }),
  
  // nodeField: The root query field
  nodeField: {
    type: nodeInterface,
    args: {
      id: { type: new GraphQLNonNull(GraphQLID) }
    },
    resolve: async (_, { id }, context) => {
      const { type, id: localId } = fromGlobalId(id);
      
      // Route to appropriate loader based on type
      switch(type) {
        case 'User':
          return context.loaders.user.load(localId);
        case 'Post':
          return context.loaders.post.load(localId);
        default:
          return null;
      }
    }
  }
};

Step 3: Add ID resolver to each type

const UserType = new GraphQLObjectType({
  name: 'User',
  interfaces: [nodeInterface],
  fields: {
    id: {
      type: new GraphQLNonNull(GraphQLID),
      resolve: (user) => toGlobalId('User', user.id)
    },
    name: { type: GraphQLString },
    email: { type: GraphQLString }
  }
});

πŸ’‘ Pro tip: The id resolver is called every time you query the id field, so the client always receives the encoded version automatically.

The Cache Normalization Flow πŸ“Š

When Relay receives data, here's what happens:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         RELAY CACHE NORMALIZATION               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

    πŸ“₯ Query Response Arrives
           β”‚
           ↓
    {
      user: {
        id: "VXNlcjo0Mg==",
        name: "Alice",
        posts: [{
          id: "UG9zdDoxMjM=",
          title: "Hello"
        }]
      }
    }
           β”‚
           ↓
    πŸ” Extract Objects by ID
           β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”
    ↓             ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚VXNlcjo0β”‚   β”‚UG9zdDoxβ”‚
β”‚Mg==     β”‚   β”‚MjM=    β”‚
│─────────│   │─────────│
β”‚name:    β”‚   β”‚title:   β”‚
β”‚"Alice"  β”‚   β”‚"Hello" β”‚
β”‚posts:   β”‚   β”‚         β”‚
β”‚[ref]    β”‚   β”‚         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           ↓
    βœ… Normalized Cache
    (No duplication!)

If another query fetches the same User, Relay recognizes the ID and merges the data instead of duplicating it.

Practical Examples 🎯

Example 1: Basic User Query with Global ID

GraphQL Query:

query GetUser {
  user(username: "alice") {
    id
    name
    email
  }
}

Response:

{
  "data": {
    "user": {
      "id": "VXNlcjo0Mg==",
      "name": "Alice Johnson",
      "email": "alice@example.com"
    }
  }
}

What happens behind the scenes:

  1. Server queries database: SELECT * FROM users WHERE username = 'alice'
  2. Retrieves record with local ID 42
  3. ID resolver calls: toGlobalId('User', 42)
  4. Returns encoded ID: "VXNlcjo0Mg=="
  5. Relay stores in cache with this key

Later, using the node field:

query RefetchUser {
  node(id: "VXNlcjo0Mg==") {
    id
    ... on User {
      name
      email
    }
  }
}

Relay recognizes this is the same user and can serve from cache if data is fresh!

Example 2: Relationships with Global IDs

Query with nested relationships:

query GetPostWithAuthor {
  post(id: "UG9zdDoxMjM=") {
    id
    title
    content
    author {
      id
      name
    }
    comments {
      id
      text
      author {
        id
        name
      }
    }
  }
}

Response:

{
  "data": {
    "post": {
      "id": "UG9zdDoxMjM=",
      "title": "Global IDs are awesome",
      "content": "Here's why...",
      "author": {
        "id": "VXNlcjo0Mg==",
        "name": "Alice Johnson"
      },
      "comments": [
        {
          "id": "Q29tbWVudDo3ODk=",
          "text": "Great post!",
          "author": {
            "id": "VXNlcjo4Nw==",
            "name": "Bob Smith"
          }
        },
        {
          "id": "Q29tbWVudDo3OTA=",
          "text": "Thanks for sharing",
          "author": {
            "id": "VXNlcjo0Mg==",
            "name": "Alice Johnson"
          }
        }
      ]
    }
  }
}

Cache structure after normalization:

RELAY STORE (Normalized)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ VXNlcjo0Mg==                         β”‚
β”‚ β”œβ”€ name: "Alice Johnson"             β”‚
β”‚ └─ (referenced in 2 places!)         β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ VXNlcjo4Nw==                         β”‚
β”‚ └─ name: "Bob Smith"                 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ UG9zdDoxMjM=                         β”‚
β”‚ β”œβ”€ title: "Global IDs are awesome"   β”‚
β”‚ β”œβ”€ author: β†’ VXNlcjo0Mg==           β”‚
β”‚ └─ comments: [β†’ Q29tbWVudDo3ODk=, ...β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Q29tbWVudDo3ODk=                     β”‚
β”‚ β”œβ”€ text: "Great post!"               β”‚
β”‚ └─ author: β†’ VXNlcjo4Nw==           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Notice Alice appears twice in the response (as post author and comment author), but Relay stores her data only once! πŸŽ‰

Example 3: Mutations Returning Global IDs

Mutation:

mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    post {
      id
      title
      author {
        id
        name
      }
    }
  }
}

Variables:

{
  "input": {
    "title": "My New Post",
    "content": "Exciting content here",
    "authorId": "VXNlcjo0Mg=="
  }
}

Server-side implementation:

const createPost = {
  type: CreatePostPayload,
  args: {
    input: { type: new GraphQLNonNull(CreatePostInput) }
  },
  resolve: async (_, { input }, context) => {
    // Decode the author's global ID
    const { id: authorId } = fromGlobalId(input.authorId);
    
    // Create post in database
    const post = await db.posts.create({
      title: input.title,
      content: input.content,
      authorId: authorId
    });
    
    // Load author for response
    const author = await context.loaders.user.load(authorId);
    
    return {
      post: {
        ...post,
        author
      }
    };
  }
};

Response:

{
  "data": {
    "createPost": {
      "post": {
        "id": "UG9zdDoxMjQ=",
        "title": "My New Post",
        "author": {
          "id": "VXNlcjo0Mg==",
          "name": "Alice Johnson"
        }
      }
    }
  }
}

Relay automatically adds the new post to the cache and updates Alice's cached data if needed!

Example 4: Polymorphic Node Queries

Query for mixed types:

query GetMultipleNodes {
  nodes(ids: [
    "VXNlcjo0Mg==",
    "UG9zdDoxMjM=",
    "Q29tbWVudDo3ODk="
  ]) {
    id
    __typename
    ... on User {
      name
      email
    }
    ... on Post {
      title
      content
    }
    ... on Comment {
      text
    }
  }
}

Response:

{
  "data": {
    "nodes": [
      {
        "id": "VXNlcjo0Mg==",
        "__typename": "User",
        "name": "Alice Johnson",
        "email": "alice@example.com"
      },
      {
        "id": "UG9zdDoxMjM=",
        "__typename": "Post",
        "title": "Global IDs are awesome",
        "content": "Here's why..."
      },
      {
        "id": "Q29tbWVudDo3ODk=",
        "__typename": "Comment",
        "text": "Great post!"
      }
    ]
  }
}

This demonstrates the power of the Node interfaceβ€”fetch any object from anywhere in your graph with a single field!

Common Mistakes ⚠️

Mistake 1: Decoding IDs on the Client

❌ Wrong:

// Client code - DON'T DO THIS!
function getUserIdFromGlobalId(globalId) {
  const decoded = atob(globalId); // "User:42"
  return decoded.split(':')[1];   // "42"
}

const userId = getUserIdFromGlobalId(user.id);

βœ… Right:

// Client code - treat IDs as opaque
const userId = user.id; // Just use it directly

// Pass the entire global ID to mutations
mutation({
  variables: { userId: user.id }
});

Why it's wrong: If the server changes its encoding scheme (e.g., adds versioning, uses a different format), your client breaks. Global IDs are contracts, not implementation details.

Mistake 2: Not Implementing Node Interface

❌ Wrong:

type User {
  id: ID!  # Just a regular ID field
  name: String!
}

type Query {
  user(id: ID!): User
}

βœ… Right:

type User implements Node {
  id: ID!  # Global ID, properly encoded
  name: String!
}

type Query {
  user(id: ID!): User
  node(id: ID!): Node  # Relay requires this!
}

Why it matters: Without the Node interface and node field, Relay can't refetch objects automatically or normalize the cache properly.

Mistake 3: Inconsistent ID Encoding

❌ Wrong:

// Sometimes using one format
toGlobalId('User', 42)     // "VXNlcjo0Mg=="

// Other times using another
toGlobalId('user', 42)     // "dXNlcjo0Mg==" (lowercase!)
toGlobalId('Users', 42)    // "VXNlcnM6NDI=" (plural!)

βœ… Right:

// Always use the exact GraphQL type name
toGlobalId('User', 42)     // Consistent!
toGlobalId('User', 99)     // Consistent!
toGlobalId('Post', 123)    // Consistent!

Why it matters: The typename in the encoded ID must match your GraphQL type name exactly for the node field resolver to route correctly.

Mistake 4: Exposing Raw Database IDs

❌ Wrong:

type User implements Node {
  id: ID!          # Global ID: "VXNlcjo0Mg=="
  databaseId: Int! # Exposing: 42
  name: String!
}

βœ… Right:

type User implements Node {
  id: ID!  # Only expose the global ID
  name: String!
}

Why it matters: Exposing raw database IDs:

  • Reveals your internal data structure
  • Creates security risks (sequential ID guessing)
  • Encourages clients to build dependencies on your database schema
  • Prevents you from changing databases or sharding strategies

Mistake 5: Not Handling ID Decode Errors

❌ Wrong:

resolve: async (_, { id }) => {
  const { type, id: localId } = fromGlobalId(id);
  return loadObject(type, localId);
}

βœ… Right:

resolve: async (_, { id }) => {
  let decoded;
  try {
    decoded = fromGlobalId(id);
  } catch (error) {
    // Invalid Base64 or wrong format
    throw new Error('Invalid node ID');
  }
  
  const { type, id: localId } = decoded;
  
  if (!type || !localId) {
    throw new Error('Malformed node ID');
  }
  
  return loadObject(type, localId);
}

Why it matters: Clients might send malformed IDs (typos, corruption, malicious input). Always validate before using!

Advanced Patterns πŸš€

Pattern 1: Composite IDs

For objects identified by multiple fields:

function toGlobalId(type, ...keys) {
  const str = `${type}:${keys.join(':')}}`;
  return Buffer.from(str).toString('base64');
}

// Usage:
toGlobalId('PostLike', userId, postId)
// β†’ Encodes "PostLike:42:123"

Pattern 2: Versioned IDs

Include version information for migrations:

function toGlobalId(type, id, version = 'v1') {
  const str = `${version}:${type}:${id}`;
  return Buffer.from(str).toString('base64');
}

function fromGlobalId(globalId) {
  const str = Buffer.from(globalId, 'base64').toString('utf-8');
  const [version, type, id] = str.split(':');
  return { version, type, id };
}

This allows you to change encoding schemes while supporting legacy IDs!

Pattern 3: Type-Safe IDs (TypeScript)

type GlobalID<T extends string> = string & { __type: T };

function toGlobalId<T extends string>(
  type: T,
  id: string | number
): GlobalID<T> {
  const str = `${type}:${id}`;
  return Buffer.from(str).toString('base64') as GlobalID<T>;
}

type UserID = GlobalID<'User'>;
type PostID = GlobalID<'Post'>;

// Type-safe usage:
const userId: UserID = toGlobalId('User', 42);
const postId: PostID = toGlobalId('Post', 123);

// Compiler catches this:
// const wrong: UserID = toGlobalId('Post', 123); // ❌ Error!

Key Takeaways 🎯

πŸ“‹ Quick Reference Card

Node InterfaceEvery cacheable type must implement it
node FieldRoot query field that fetches any object by ID
Global ID FormatBase64("TypeName:LocalID") - opaque to clients
toGlobalId()Encodes typename + local ID β†’ global ID
fromGlobalId()Decodes global ID β†’ {type, id}
Cache KeyRelay uses global IDs as cache keys
NormalizationSame ID = same cache entry (no duplication)
Opaque IDsClients never decodeβ€”server owns format

Remember:

  1. Every refetchable object needs a global ID - implement the Node interface
  2. Use Base64 encoding with "TypeName:LocalID" format for consistency
  3. Global IDs are opaque - clients treat them as strings, never decode
  4. The node field is required - it's how Relay refetches objects
  5. Cache normalization depends on IDs - same ID = same object = no duplication
  6. Always validate decoded IDs - handle errors gracefully on the server
  7. Use the exact GraphQL typename in encoding for proper type routing

πŸ“š Further Study