Skip to main content

Best Practices & Performance

Learn essential patterns and optimization techniques for building high-performance graph applications with Ductape.

Data Modeling

Use Descriptive Labels

//  Good - clear and specific
await ductape.graph.createNode({
labels: ['Person', 'Employee', 'Engineer'],
properties: { name: 'Alice' },
});

// ❌ Avoid - too generic
await ductape.graph.createNode({
labels: ['Node'],
properties: { name: 'Alice', type: 'person' },
});

Benefits:

  • Faster queries (filtered at label level)
  • Better code readability
  • Easier to create targeted indexes

Model Relationships Correctly

//  Good - relationship types are verbs
await ductape.graph.createRelationship({
type: 'WORKS_FOR',
startNodeId: personId,
endNodeId: companyId,
});

await ductape.graph.createRelationship({
type: 'FRIENDS_WITH',
startNodeId: user1Id,
endNodeId: user2Id,
});

// ❌ Avoid - nouns or unclear relationships
await ductape.graph.createRelationship({
type: 'PERSON_COMPANY',
startNodeId: personId,
endNodeId: companyId,
});

Property vs. Node Decision

Use a property when:

  • Simple value (string, number, date)
  • Doesn't need its own relationships
  • Not queried independently
//  Property
await ductape.graph.createNode({
labels: ['Person'],
properties: {
name: 'Alice',
age: 28,
city: 'New York', // Simple value
},
});

Use a node when:

  • Has its own properties
  • Can have relationships
  • Queried independently
  • Reused across many nodes
//  Separate nodes
const alice = await ductape.graph.createNode({
labels: ['Person'],
properties: { name: 'Alice', age: 28 },
});

const nyc = await ductape.graph.createNode({
labels: ['City'],
properties: {
name: 'New York',
population: 8000000,
timezone: 'EST',
},
});

await ductape.graph.createRelationship({
type: 'LIVES_IN',
startNodeId: alice.node.id,
endNodeId: nyc.node.id,
properties: { since: 2020 },
});

Avoid Property Overloading

// ❌ Bad - properties doing too much
await ductape.graph.createNode({
labels: ['User'],
properties: {
name: 'Alice',
emails: 'alice@work.com,alice@personal.com', // Comma-separated
skills: 'JS,TS,Python,React', // Comma-separated
},
});

// Good - structured properly
await ductape.graph.createNode({
labels: ['User'],
properties: {
name: 'Alice',
emails: ['alice@work.com', 'alice@personal.com'], // Array
skills: ['JavaScript', 'TypeScript', 'Python', 'React'], // Array
},
});

// Even better - use nodes for complex relationships
const alice = await ductape.graph.createNode({
labels: ['User'],
properties: { name: 'Alice' },
});

const javascript = await ductape.graph.createNode({
labels: ['Skill'],
properties: { name: 'JavaScript' },
});

await ductape.graph.createRelationship({
type: 'HAS_SKILL',
startNodeId: alice.node.id,
endNodeId: javascript.node.id,
properties: {
level: 'expert',
yearsExperience: 5,
},
});

Indexing Strategy

Index High-Cardinality Properties

//  Good - index unique or near-unique values
await ductape.graph.createNodeIndex({
name: 'idx_user_email',
type: NodeIndexType.BTREE,
label: 'User',
properties: ['email'], // High cardinality
});

await ductape.graph.createNodeIndex({
name: 'idx_product_sku',
type: NodeIndexType.BTREE,
label: 'Product',
properties: ['sku'], // High cardinality
});

// ❌ Don't index low-cardinality properties alone
// Bad index on boolean (only 2 values)
await ductape.graph.createNodeIndex({
name: 'idx_user_active',
type: NodeIndexType.BTREE,
label: 'User',
properties: ['isActive'], // Only true/false
});

Use Composite Indexes Wisely

//  Good - matches query patterns
await ductape.graph.createNodeIndex({
name: 'idx_order_user_status',
type: NodeIndexType.BTREE,
label: 'Order',
properties: ['userId', 'status'], // Most selective first
});

// Efficiently supports:
const orders = await ductape.graph.findNodes({
labels: ['Order'],
where: {
userId: '123',
status: 'pending',
},
});

Create Constraints for Uniqueness

//  Use constraints instead of just indexes
await ductape.graph.createNodeConstraint({
name: 'unique_user_email',
type: NodeConstraintType.UNIQUE,
label: 'User',
properties: ['email'],
});
// Automatically creates an index + enforces uniqueness

Query Optimization

Filter Early

//  Good - filter at query time
const users = await ductape.graph.findNodes({
labels: ['User'],
where: {
status: 'active',
city: 'New York',
},
limit: 10,
});

// ❌ Bad - fetch all then filter in code
const allUsers = await ductape.graph.findNodes({
labels: ['User'],
});
const filtered = allUsers.nodes.filter(
u => u.properties.status === 'active' && u.properties.city === 'New York'
).slice(0, 10);

Use Specific Labels

//  Good - specific label
const engineers = await ductape.graph.findNodes({
labels: ['Engineer'],
where: { experience: { $GT: 5 } },
});

// ❌ Slower - generic label + property filter
const engineers = await ductape.graph.findNodes({
labels: ['Person'],
where: {
type: 'engineer',
experience: { $GT: 5 },
},
});

Limit Traversal Depth

//  Good - reasonable depth
const network = await ductape.graph.traverse({
startNodeId: userId,
relationshipTypes: ['FRIENDS_WITH'],
maxDepth: 3, // Friends of friends of friends
});

// ❌ Dangerous - could explore millions of nodes
const network = await ductape.graph.traverse({
startNodeId: userId,
maxDepth: 10, // Exponential growth
});

Use Relationship Types

//  Good - specific relationship types
const friends = await ductape.graph.traverse({
startNodeId: userId,
relationshipTypes: ['FRIENDS_WITH', 'KNOWS'],
maxDepth: 2,
});

// ❌ Slower - follows all relationships
const connections = await ductape.graph.traverse({
startNodeId: userId,
// No relationshipTypes specified
maxDepth: 2,
});

Paginate Large Result Sets

//  Good - paginate results
async function getUsersPaginated(page: number = 1, pageSize: number = 50) {
const offset = (page - 1) * pageSize;

const result = await ductape.graph.findNodes({
labels: ['User'],
where: { status: 'active' },
limit: pageSize,
skip: offset,
});

return {
users: result.nodes,
page,
pageSize,
hasMore: result.nodes.length === pageSize,
};
}

Performance Patterns

Batch Operations in Transactions

//  Good - batch in single transaction
await ductape.graph.executeTransaction(async (tx) => {
for (const userData of largeUserList) {
await ductape.graph.createNode({
labels: ['User'],
properties: userData,
}, tx);
}
});

// ❌ Bad - separate transaction per operation
for (const userData of largeUserList) {
await ductape.graph.createNode({
labels: ['User'],
properties: userData,
});
}

Cache Frequently Accessed Data

//  Good - cache popular queries
const cache = new Map<string, any>();

async function getPopularPosts() {
const cacheKey = 'popular_posts';
const cached = cache.get(cacheKey);

if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) {
return cached.data; // Return cached data (5 min TTL)
}

const posts = await ductape.graph.findNodes({
labels: ['Post'],
where: { likes: { $GT: 1000 } },
limit: 10,
});

cache.set(cacheKey, {
data: posts.nodes,
timestamp: Date.now(),
});

return posts.nodes;
}

Use Connection Pooling

//  Good - reuse connections
const ductape = new Ductape({
workspace_id: 'workspace-123',
user_id: 'user-456',
private_key: 'your-private-key',
});

// Register once at startup
await ductape.graph.register({
tag: 'main-graph',
driver: GraphDriver.NEO4J,
config: {
uri: 'neo4j://localhost:7687',
username: 'neo4j',
password: 'password',
},
options: {
maxConnectionPoolSize: 50,
connectionTimeout: 30000,
},
});

// Connect once
await ductape.graph.connect({ tag: 'main-graph' });

// Reuse connection for all operations
// Connection is automatically pooled

Avoid N+1 Query Problems

// ❌ Bad - N+1 queries
const users = await ductape.graph.findNodes({
labels: ['User'],
limit: 10,
});

// Separate query for each user's posts (N queries)
for (const user of users.nodes) {
const posts = await ductape.graph.findRelationships({
startNodeId: user.id,
type: 'POSTED',
});
user.posts = posts.relationships;
}

// Good - single query with pattern
const usersWithPosts = await ductape.graph.query({
query: `
MATCH (u:User)-[r:POSTED]->(p:Post)
WHERE u.status = $status
RETURN u, collect({post: p, relationship: r}) as posts
LIMIT 10
`,
params: { status: 'active' },
});

Data Consistency

Use Transactions for Multi-Step Operations

//  Always use transactions for related operations
await ductape.graph.executeTransaction(async (tx) => {
const order = await ductape.graph.createNode({
labels: ['Order'],
properties: { total: 100, status: 'pending' },
}, tx);

await ductape.graph.updateNode({
id: userId,
properties: { orderCount: { $INCREMENT: 1 } },
}, tx);

await ductape.graph.createRelationship({
type: 'PLACED',
startNodeId: userId,
endNodeId: order.node.id,
}, tx);
});

Validate Data Before Writing

//  Good - validate first
function validateUser(data: any) {
if (!data.email || !data.email.includes('@')) {
throw new Error('Invalid email');
}
if (!data.name || data.name.length < 2) {
throw new Error('Name too short');
}
return true;
}

async function createUser(data: any) {
validateUser(data);

return ductape.graph.createNode({
labels: ['User'],
properties: data,
});
}

Use Merge for Idempotent Operations

//  Good - merge is idempotent
const user = await ductape.graph.mergeNode({
labels: ['User'],
matchProperties: { email: 'alice@example.com' },
onCreate: {
email: 'alice@example.com',
name: 'Alice',
createdAt: new Date(),
},
onMatch: {
lastSeen: new Date(),
},
});
// Safe to call multiple times

Schema Design

Denormalize Carefully

Sometimes denormalization improves performance:

// Store frequently accessed counts
await ductape.graph.createNode({
labels: ['User'],
properties: {
name: 'Alice',
followerCount: 1234, // Denormalized count
followingCount: 567, // Denormalized count
},
});

// Update counts when relationships change
await ductape.graph.executeTransaction(async (tx) => {
// Create follow relationship
await ductape.graph.createRelationship({
type: 'FOLLOWS',
startNodeId: user1Id,
endNodeId: user2Id,
}, tx);

// Update denormalized counts
await ductape.graph.updateNode({
id: user1Id,
properties: { followingCount: { $INCREMENT: 1 } },
}, tx);

await ductape.graph.updateNode({
id: user2Id,
properties: { followerCount: { $INCREMENT: 1 } },
}, tx);
});

Use Intermediate Nodes for Complex Relationships

// ❌ Basic - loses information
await ductape.graph.createRelationship({
type: 'ENROLLED_IN',
startNodeId: studentId,
endNodeId: courseId,
properties: {
grade: 'A',
semester: 'Fall 2024',
},
});

// Better - enrollment as node
const enrollment = await ductape.graph.createNode({
labels: ['Enrollment'],
properties: {
grade: 'A',
semester: 'Fall 2024',
credits: 3,
status: 'completed',
},
});

await ductape.graph.createRelationship({
type: 'HAS_ENROLLMENT',
startNodeId: studentId,
endNodeId: enrollment.node.id,
});

await ductape.graph.createRelationship({
type: 'FOR_COURSE',
startNodeId: enrollment.node.id,
endNodeId: courseId,
});

Version Your Schema

// Add schema version to nodes
await ductape.graph.createNode({
labels: ['User'],
properties: {
name: 'Alice',
email: 'alice@example.com',
schemaVersion: 2, // Track schema version
},
});

// Handle multiple versions
async function getUser(id: string) {
const user = await ductape.graph.findNodeById(id);

if (user.properties.schemaVersion === 1) {
// Migrate old schema
return migrateUserV1ToV2(user);
}

return user;
}

Error Handling

Catch Specific Errors

//  Good - handle specific errors
try {
await ductape.graph.createNode({
labels: ['User'],
properties: { email: 'alice@example.com' },
});
} catch (error) {
if (error.message.includes('constraint')) {
console.log('Email already exists');
// Return existing user or show error to user
} else if (error.message.includes('connection')) {
console.log('Database connection failed');
// Retry or show maintenance message
} else {
console.error('Unexpected error:', error);
// Log and report
}
}

Implement Retry Logic

//  Retry transient errors
async function withRetry<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
delay: number = 1000
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (error) {
const isTransient =
error.message.includes('connection') ||
error.message.includes('timeout') ||
error.message.includes('deadlock');

if (isTransient && i < maxRetries - 1) {
console.log(`Retry ${i + 1}/${maxRetries}`);
await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
continue;
}
throw error;
}
}
throw new Error('Max retries exceeded');
}

// Usage
const user = await withRetry(() =>
ductape.graph.createNode({
labels: ['User'],
properties: { email: 'alice@example.com' },
})
);

Monitoring and Debugging

Log Slow Queries

//  Monitor query performance
async function timedQuery<T>(
name: string,
operation: () => Promise<T>
): Promise<T> {
const start = Date.now();

try {
const result = await operation();
const duration = Date.now() - start;

if (duration > 1000) {
console.warn(`Slow query: ${name} took ${duration}ms`);
}

return result;
} catch (error) {
console.error(`Query failed: ${name}`, error);
throw error;
}
}

// Usage
const users = await timedQuery('find-active-users', () =>
ductape.graph.findNodes({
labels: ['User'],
where: { status: 'active' },
})
);

Use Explain for Query Analysis

// Analyze query performance (Neo4j)
const plan = await ductape.graph.query({
query: `
EXPLAIN MATCH (u:User)-[:FRIENDS_WITH]->(f:User)
WHERE u.city = $city
RETURN f.name
`,
params: { city: 'New York' },
});

console.log('Query plan:', plan.records);
// Look for "NodeByLabelScan" vs "NodeIndexSeek"

Track Database Statistics

// Monitor graph health
async function getGraphHealth() {
const stats = await ductape.graph.getStatistics();

console.log('Nodes:', stats.nodeCount);
console.log('Relationships:', stats.relationshipCount);
console.log('Labels:', stats.labels);
console.log('Relationship types:', stats.relationshipTypes);

return stats;
}

// Run periodically
setInterval(getGraphHealth, 5 * 60 * 1000); // Every 5 minutes

Security

Sanitize User Input

//  Good - use parameterized queries
async function findUserByEmail(email: string) {
return ductape.graph.findNodes({
labels: ['User'],
where: { email }, // Safely parameterized
});
}

// ❌ Dangerous - injection risk
async function findUserByEmailUnsafe(email: string) {
return ductape.graph.query({
query: `MATCH (u:User {email: "${email}"}) RETURN u`, // DON'T DO THIS
});
}

Use Read-Only Transactions for Queries

//  Good - read-only transaction for analytics
await ductape.graph.executeTransaction(
async (tx) => {
const stats = await ductape.graph.getStatistics(tx);
const users = await ductape.graph.findNodes({
labels: ['User'],
}, tx);
return { stats, userCount: users.nodes.length };
},
{ readOnly: true }
);

Limit Result Sizes

//  Always limit query results
async function searchUsers(query: string) {
return ductape.graph.findNodes({
labels: ['User'],
where: {
name: { $CONTAINS: query },
},
limit: 100, // Prevent unbounded results
});
}

Testing

Use Transactions in Tests

//  Good - test in transaction, rollback after
import { describe, it, beforeEach, afterEach } from 'vitest';

describe('User operations', () => {
let tx: IGraphTransaction;

beforeEach(async () => {
tx = await ductape.graph.beginTransaction();
});

afterEach(async () => {
await ductape.graph.rollbackTransaction(tx);
});

it('should create user', async () => {
const user = await ductape.graph.createNode({
labels: ['User'],
properties: { email: 'test@example.com' },
}, tx);

expect(user.node.properties.email).toBe('test@example.com');
// Changes rolled back automatically
});
});

Test with Realistic Data

//  Create test fixtures
async function setupTestData() {
return ductape.graph.executeTransaction(async (tx) => {
const users = [];
for (let i = 0; i < 10; i++) {
const user = await ductape.graph.createNode({
labels: ['User'],
properties: {
name: `User ${i}`,
email: `user${i}@test.com`,
},
}, tx);
users.push(user.node);
}

// Create relationships
for (let i = 0; i < users.length - 1; i++) {
await ductape.graph.createRelationship({
type: 'FRIENDS_WITH',
startNodeId: users[i].id,
endNodeId: users[i + 1].id,
}, tx);
}

return users;
});
}

Deployment

Use Environment Variables

//  Good - environment-based config
const config = {
uri: process.env.NEO4J_URI,
username: process.env.NEO4J_USERNAME,
password: process.env.NEO4J_PASSWORD,
};

await ductape.graph.register({
tag: 'main-graph',
driver: GraphDriver.NEO4J,
config,
});

Connection Pooling in Production

//  Configure for production load
await ductape.graph.register({
tag: 'main-graph',
driver: GraphDriver.NEO4J,
config: {
uri: process.env.NEO4J_URI,
username: process.env.NEO4J_USERNAME,
password: process.env.NEO4J_PASSWORD,
},
options: {
maxConnectionPoolSize: 50,
connectionTimeout: 30000,
maxTransactionRetryTime: 30000,
},
});

Health Checks

//  Implement health check endpoint
async function healthCheck() {
try {
await ductape.graph.testConnection({ tag: 'main-graph' });
return { status: 'healthy', database: 'connected' };
} catch (error) {
return { status: 'unhealthy', error: error.message };
}
}

// Express example
app.get('/health', async (req, res) => {
const health = await healthCheck();
res.status(health.status === 'healthy' ? 200 : 503).json(health);
});

Summary Checklist

Data Modeling:

  • Use descriptive labels
  • Relationships are verbs
  • Properties for simple values, nodes for complex entities

Performance:

  • Create indexes on frequently queried properties
  • Use constraints for uniqueness
  • Limit traversal depth
  • Batch operations in transactions
  • Paginate large results

Data Integrity:

  • Use transactions for multi-step operations
  • Validate data before writing
  • Use merge for idempotent operations

Monitoring:

  • Log slow queries
  • Track database statistics
  • Implement health checks

Security:

  • Use parameterized queries
  • Sanitize user input
  • Limit result sizes

Next Steps

See Also