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:
- An
idfield (non-null ID type) that's globally unique - 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:
| Step | Value | Description |
|---|---|---|
| 1 | User, 42 | Type name and local database ID |
| 2 | "User:42" | Concatenate with colon separator |
| 3 | "VXNlcjo0Mg==" | Base64 encode the string |
Decoding process:
| Step | Value | Description |
|---|---|---|
| 1 | "VXNlcjo0Mg==" | Received global ID |
| 2 | "User:42" | Base64 decode |
| 3 | Type="User", ID=42 | Split 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
- URL-safe: Can be used in query parameters without escaping
- Opaque: Hides internal database structure from clients
- Type information: Includes typename for server-side routing
- 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:
- Server queries database:
SELECT * FROM users WHERE username = 'alice' - Retrieves record with local ID
42 - ID resolver calls:
toGlobalId('User', 42) - Returns encoded ID:
"VXNlcjo0Mg==" - 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 Interface | Every cacheable type must implement it |
| node Field | Root query field that fetches any object by ID |
| Global ID Format | Base64("TypeName:LocalID") - opaque to clients |
| toGlobalId() | Encodes typename + local ID β global ID |
| fromGlobalId() | Decodes global ID β {type, id} |
| Cache Key | Relay uses global IDs as cache keys |
| Normalization | Same ID = same cache entry (no duplication) |
| Opaque IDs | Clients never decodeβserver owns format |
Remember:
- Every refetchable object needs a global ID - implement the Node interface
- Use Base64 encoding with "TypeName:LocalID" format for consistency
- Global IDs are opaque - clients treat them as strings, never decode
- The
nodefield is required - it's how Relay refetches objects - Cache normalization depends on IDs - same ID = same object = no duplication
- Always validate decoded IDs - handle errors gracefully on the server
- Use the exact GraphQL typename in encoding for proper type routing
π Further Study
- Relay Global Object Identification Spec - Official specification
- GraphQL Global Object Identification - GraphQL.org guide
- Relay Modern Cache Normalization - How IDs enable caching