Front-end Engineering Lab

Parallel and Dependent Queries

Efficiently fetch multiple related data sources

Optimize data fetching by running queries in parallel or sequentially when needed. This guide covers both patterns.

🎯 The Problem

// ❌ BAD: Sequential (waterfall)
const user = await fetchUser();
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
// Total time: 600ms + 400ms + 300ms = 1300ms

// ✅ GOOD: Parallel (when possible)
const [user, categories, settings] = await Promise.all([
  fetchUser(),
  fetchCategories(),
  fetchSettings()
]);
// Total time: max(600ms, 200ms, 150ms) = 600ms

⚡ Pattern 1: Parallel Queries

Fetch independent data simultaneously.

import { useQueries } from '@tanstack/react-query';

function Dashboard() {
  const results = useQueries({
    queries: [
      {
        queryKey: ['user'],
        queryFn: fetchUser
      },
      {
        queryKey: ['stats'],
        queryFn: fetchStats
      },
      {
        queryKey: ['notifications'],
        queryFn: fetchNotifications
      },
      {
        queryKey: ['settings'],
        queryFn: fetchSettings
      }
    ]
  });
  
  // All queries run in parallel!
  const [userQuery, statsQuery, notificationsQuery, settingsQuery] = results;
  
  // Check loading state
  const isLoading = results.some(query => query.isLoading);
  
  if (isLoading) return <Spinner />;
  
  return (
    <div>
      <UserProfile user={userQuery.data} />
      <Stats data={statsQuery.data} />
      <Notifications items={notificationsQuery.data} />
      <Settings data={settingsQuery.data} />
    </div>
  );
}

With Individual Hooks

function Dashboard() {
  // All three queries run in parallel
  const { data: user } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser
  });
  
  const { data: stats } = useQuery({
    queryKey: ['stats'],
    queryFn: fetchStats
  });
  
  const { data: notifications } = useQuery({
    queryKey: ['notifications'],
    queryFn: fetchNotifications
  });
  
  return (
    <div>
      <UserProfile user={user} />
      <Stats data={stats} />
      <Notifications items={notifications} />
    </div>
  );
}

Pros:

  • ✅ Fastest possible loading
  • ✅ Simple to implement
  • ✅ Independent failures

Cons:

  • ⚠️ Higher initial network usage
  • ⚠️ May fetch unnecessary data

🔗 Pattern 2: Dependent Queries (Waterfall)

Fetch data that depends on previous data.

function UserPosts({ userId }: { userId: string }) {
  // First, fetch user
  const { data: user, isLoading: userLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId)
  });
  
  // Then fetch user's posts (depends on user)
  const { data: posts, isLoading: postsLoading } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => fetchPosts(user!.id),
    enabled: !!user, // Only run when user exists
  });
  
  if (userLoading) return <Spinner />;
  if (!user) return <div>User not found</div>;
  
  if (postsLoading) return <Spinner />;
  
  return (
    <div>
      <h2>{user.name}'s Posts</h2>
      {posts?.map(post => (
        <Post key={post.id} post={post} />
      ))}
    </div>
  );
}

Multi-Level Dependencies

function CommentThread({ postId }: { postId: string }) {
  // Level 1: Fetch post
  const { data: post } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => fetchPost(postId)
  });
  
  // Level 2: Fetch author (depends on post)
  const { data: author } = useQuery({
    queryKey: ['user', post?.authorId],
    queryFn: () => fetchUser(post!.authorId),
    enabled: !!post?.authorId
  });
  
  // Level 3: Fetch comments (depends on post)
  const { data: comments } = useQuery({
    queryKey: ['comments', postId],
    queryFn: () => fetchComments(postId),
    enabled: !!post
  });
  
  // Level 4: Fetch comment authors (depends on comments)
  const commentAuthors = useQueries({
    queries: (comments || []).map(comment => ({
      queryKey: ['user', comment.authorId],
      queryFn: () => fetchUser(comment.authorId)
    }))
  });
  
  return (
    <div>
      <Post post={post} author={author} />
      <Comments comments={comments} authors={commentAuthors} />
    </div>
  );
}

Pros:

  • ✅ Correct data dependencies
  • ✅ Doesn't fetch unnecessary data
  • ✅ Clear data flow

Cons:

  • ⚠️ Slower (waterfall effect)
  • ⚠️ Can't parallelize

🎯 Pattern 3: Optimistic Dependencies

Start dependent query early with estimated data.

function UserProfile({ username }: { username: string }) {
  // Fetch user by username
  const { data: user } = useQuery({
    queryKey: ['user', username],
    queryFn: () => fetchUserByUsername(username)
  });
  
  // Start fetching posts immediately (optimistic)
  // Even though we don't have userId yet
  const { data: posts } = useQuery({
    queryKey: ['posts', username], // Use username as key
    queryFn: async () => {
      // Wait for user if not ready
      if (!user) {
        // Fetch user inline (will use cache)
        const u = await fetchUserByUsername(username);
        return fetchPosts(u.id);
      }
      return fetchPosts(user.id);
    }
  });
  
  return (
    <div>
      <UserInfo user={user} />
      <Posts posts={posts} />
    </div>
  );
}

🚀 Pattern 4: Parallel + Dependent (Mixed)

Combine parallel and sequential fetching.

function Dashboard() {
  // Step 1: Fetch user (required for everything)
  const { data: user } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser
  });
  
  // Step 2: Once user loaded, fetch multiple things in parallel
  const results = useQueries({
    queries: [
      {
        queryKey: ['posts', user?.id],
        queryFn: () => fetchPosts(user!.id),
        enabled: !!user
      },
      {
        queryKey: ['followers', user?.id],
        queryFn: () => fetchFollowers(user!.id),
        enabled: !!user
      },
      {
        queryKey: ['following', user?.id],
        queryFn: () => fetchFollowing(user!.id),
        enabled: !!user
      }
    ]
  });
  
  const [postsQuery, followersQuery, followingQuery] = results;
  
  return (
    <div>
      <UserHeader user={user} />
      <Posts posts={postsQuery.data} />
      <Followers data={followersQuery.data} />
      <Following data={followingQuery.data} />
    </div>
  );
}

📦 Pattern 5: Batched Queries

Batch multiple queries into one request.

// Frontend
async function batchFetch(queries: Array<{ key: string; params: any }>) {
  const response = await fetch('/api/batch', {
    method: 'POST',
    body: JSON.stringify({ queries })
  });
  
  return response.json();
}

// Usage
function Dashboard() {
  const { data } = useQuery({
    queryKey: ['dashboard-batch'],
    queryFn: () => batchFetch([
      { key: 'user', params: {} },
      { key: 'posts', params: { limit: 10 } },
      { key: 'stats', params: {} }
    ])
  });
  
  return (
    <div>
      <UserProfile user={data?.user} />
      <Posts posts={data?.posts} />
      <Stats data={data?.stats} />
    </div>
  );
}

// Backend
app.post('/api/batch', async (req, res) => {
  const { queries } = req.body;
  
  const results = await Promise.all(
    queries.map(async (query) => {
      switch (query.key) {
        case 'user':
          return { key: 'user', data: await fetchUser() };
        case 'posts':
          return { key: 'posts', data: await fetchPosts(query.params) };
        case 'stats':
          return { key: 'stats', data: await fetchStats() };
        default:
          throw new Error(`Unknown query: ${query.key}`);
      }
    })
  );
  
  // Convert array to object
  const data = results.reduce((acc, result) => {
    acc[result.key] = result.data;
    return acc;
  }, {});
  
  res.json(data);
});

🔄 Pattern 6: GraphQL (Automatic Batching)

Let GraphQL handle batching and dependencies.

import { useQuery, gql } from '@apollo/client';

const DASHBOARD_QUERY = gql`
  query Dashboard {
    user {
      id
      name
      email
    }
    posts(limit: 10) {
      id
      title
      author {
        name
      }
    }
    stats {
      views
      likes
    }
  }
`;

function Dashboard() {
  const { data, loading } = useQuery(DASHBOARD_QUERY);
  
  if (loading) return <Spinner />;
  
  return (
    <div>
      <UserProfile user={data.user} />
      <Posts posts={data.posts} />
      <Stats data={data.stats} />
    </div>
  );
}

⚙️ Pattern 7: Smart Prefetching

Prefetch dependent data proactively.

function PostList() {
  const queryClient = useQueryClient();
  
  const { data: posts } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    onSuccess: (posts) => {
      // Prefetch author data for all posts
      posts.forEach(post => {
        queryClient.prefetchQuery({
          queryKey: ['user', post.authorId],
          queryFn: () => fetchUser(post.authorId)
        });
      });
    }
  });
  
  return (
    <div>
      {posts?.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

function PostCard({ post }: { post: Post }) {
  // Author already prefetched, instant load!
  const { data: author } = useQuery({
    queryKey: ['user', post.authorId],
    queryFn: () => fetchUser(post.authorId)
  });
  
  return (
    <div>
      <h3>{post.title}</h3>
      <p>By {author?.name}</p>
    </div>
  );
}

📊 Comparison Table

PatternSpeedComplexityBest For
Parallel⚡⚡⚡⚡⚡LowIndependent data
Dependent⚡⚡LowRequired sequence
Optimistic⚡⚡⚡⚡MediumPredictable deps
Mixed⚡⚡⚡⚡MediumReal-world apps
Batched⚡⚡⚡⚡HighMany small requests
GraphQL⚡⚡⚡⚡MediumComplex relationships
Prefetch⚡⚡⚡⚡⚡MediumKnown patterns

💡 Best Practices

1. Identify Dependencies

// ✅ GOOD: Clear dependencies
const user = await fetchUser();
const posts = await fetchPosts(user.id); // Depends on user

// ❌ BAD: Hidden dependencies
const posts = await fetchPosts(); // Which user?

2. Minimize Waterfalls

// ❌ BAD: 3-level waterfall
const user = await fetchUser();
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
// 600ms + 400ms + 300ms = 1300ms

// ✅ GOOD: Parallel when possible
const user = await fetchUser();
const [posts, followers] = await Promise.all([
  fetchPosts(user.id),
  fetchFollowers(user.id)
]);
// 600ms + max(400ms, 200ms) = 1000ms

3. Use enabled for Dependencies

// ✅ GOOD: Only fetch when ready
const { data: posts } = useQuery({
  queryKey: ['posts', userId],
  queryFn: () => fetchPosts(userId),
  enabled: !!userId
});

// ❌ BAD: Fetch always
const { data: posts } = useQuery({
  queryKey: ['posts', userId],
  queryFn: () => fetchPosts(userId) // Error if userId is null!
});

🏢 Real-World Examples

GitHub

// Parallel: User info, repos, activity
// Dependent: Repo → Contributors → User details

Twitter

// Parallel: Timeline, trends, suggestions
// Dependent: Tweet → Author → Followers

LinkedIn

// Batched: Profile sections in one query
// Prefetch: Network connections on hover

📚 Key Takeaways

  1. Parallel by default - Fetch independent data together
  2. Sequential when needed - Use enabled for dependencies
  3. Prefetch predictable deps - Load before needed
  4. Batch small requests - Reduce HTTP overhead
  5. GraphQL for complex - Let server optimize
  6. Monitor waterfalls - Use React DevTools
  7. Test with slow network - Waterfalls hurt more on 3G

Optimize for the critical path first, then parallelize everything else.

On this page