DEV Community

Renato Silva
Renato Silva

Posted on

Graph Search Isn't Just a LeetCode Trick: BFS in Prod

Every few months "six degrees of separation" and graph traversal puzzles trend again, and every time the comments split into two camps: people who think BFS/DFS are pure interview theater, and people quietly using them in production without telling anyone. I'm in the second camp. Here's a real feature β€” "related feedback threads" β€” built on a boring Node/Postgres stack, where breadth-first search turned out to be exactly the right tool.

🎯 The Setup

We run a support/feedback tool where users can link feedback items to each other: "this is related to that," "this duplicates that," "this was split off from that." Over time these links form a graph. Individually each link is trivial β€” a row in a join table. But support agents kept asking a very reasonable question:

"If I'm looking at ticket #4521, what's the full cluster of stuff connected to it, even indirectly?"

That's not a JOIN. That's a graph traversal.

πŸ”§ The Problem

Our schema looks like this:

sql
CREATE TABLE feedback (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE feedback_links (
source_id INT REFERENCES feedback(id),
target_id INT REFERENCES feedback(id),
relation TEXT NOT NULL, -- 'related', 'duplicate', 'split_from'
PRIMARY KEY (source_id, target_id)
);

A single feedback item might link to 3 others, each of which links to 2 more, and so on. Agents don't just want direct neighbors β€” they want the whole connected component up to some reasonable depth, because context that's two or three hops away is often exactly what explains why a bug report and a feature request are secretly the same underlying issue.

The naive fix β€” recursive JOINs pulled straight into application code with no depth limit β€” either times out on a dense cluster or returns way more noise than an agent can use in a support ticket sidebar.

πŸ•ΈοΈ Modeling Feedback as a Graph

Once you say "connected component up to N hops," you've already described BFS. Depth-first search would work too, but it explores one branch all the way down before backtracking, which is the wrong shape for "show me everything within 3 degrees, closest first." BFS naturally processes nodes in order of distance from the source, which maps directly onto the UI requirement: show closest-related items first, and stop expanding once you hit the depth cap.

πŸ” The BFS Implementation

We pull the edges relevant to the starting node's component lazily, level by level, straight from Postgres, and do the traversal logic in Node:

javascript
async function findRelatedFeedback(pool, startId, maxDepth = 3, maxResults = 50) {
const visited = new Set([startId]);
const result = [];
let frontier = [startId];
let depth = 0;

while (frontier.length > 0 && depth < maxDepth && result.length < maxResults) {
const { rows } = await pool.query(
SELECT source_id, target_id, relation
FROM feedback_links
WHERE source_id = ANY($1) OR target_id = ANY($1)
,
[frontier]
);

const nextFrontier = [];

for (const row of rows) {
  const neighbor = frontier.includes(row.source_id) ? row.target_id : row.source_id;
  if (!visited.has(neighbor)) {
    visited.add(neighbor);
    nextFrontier.push(neighbor);
    result.push({ id: neighbor, depth: depth + 1, relation: row.relation });
  }
}

frontier = nextFrontier;
depth++;
Enter fullscreen mode Exit fullscreen mode

}

return result;
}

This is textbook BFS with two production-shaped guardrails bolted on: maxDepth so a densely connected cluster can't blow up the response, and maxResults so a single mega-hub node (some "general feedback" catch-all ticket with 200 links) can't turn one API call into a full graph dump. Those two limits are doing more work for user experience than the algorithm itself.

🐘 Doing It in Postgres Instead

You can also push the whole traversal into the database with a recursive CTE, which is worth knowing even if you don't end up using it:

sql
WITH RECURSIVE related AS (
SELECT source_id AS id, 0 AS depth
FROM feedback WHERE id = $1
UNION
SELECT id, 0 FROM feedback WHERE id = $1

UNION ALL

SELECT
CASE WHEN fl.source_id = r.id THEN fl.target_id ELSE fl.source_id END,
r.depth + 1
FROM feedback_links fl
JOIN related r
ON fl.source_id = r.id OR fl.target_id = r.id
WHERE r.depth < 3
)
SELECT DISTINCT id, MIN(depth) AS depth
FROM related
WHERE id <> $1
GROUP BY id
ORDER BY depth;

We tried this first. It's elegant and it's fewer round trips. But recursive CTEs don't cleanly enforce a "stop after N total nodes visited, regardless of depth" limit β€” you can cap depth, but capping total breadth requires awkward window functions or a hard row limit that can cut off a level halfway through and give you an inconsistent-looking result set. Doing BFS in application code, level by level, gave us a natural point to check "have I collected enough?" between each round trip. More queries, but more control.

βš–οΈ Complexity Trade-offs at Small Scale

Here's the part that actually matters for a "boring CRUD app with a graph feature" like ours: at our scale (tens of thousands of feedback items, average node degree under 4), textbook BFS complexity of O(V + E) is a non-issue. We're not traversing millions of edges. The real costs are:

  • Round trips, not Big O. Each BFS level is a network hop to Postgres. At depth 3 that's at most 3 queries, which is fine. If we ever needed depth 10, we'd batch differently or move to a native graph store.
  • Fan-out nodes, not graph size. The actual risk isn't "the graph is too big," it's "one node has too many neighbors." A single popular feedback thread with 80 links can make one BFS level return more rows than three normal traversals combined. This is why maxResults matters more than maxDepth in practice.
  • Cycles are silent but real. Feedback links can form loops (A relates to B relates to C relates back to A), and without the visited set, plain recursive traversal would infinite-loop or duplicate work. It's the kind of bug that doesn't show up in a demo with 5 tickets and absolutely shows up once support agents start linking things liberally.

We explicitly did not reach for Neo4j or any dedicated graph database. The join table plus application-level BFS handles our volume with room to spare, and it means one less piece of infrastructure to operate. If our average node degree climbed into the hundreds, or if we needed shortest-path-with-weighted-relations queries across millions of edges, that calculus would flip. Picking the graph database on day one for a feature that queries at most a few thousand edges is optimizing for a scale problem we don't have yet.

πŸš€ Where This Goes Next

The obvious next step is weighting edges by relation type β€” a "duplicate" link probably matters more than a "loosely related" one β€” which turns this from plain BFS into something closer to Dijkstra territory. We haven't needed it yet, but it's a good sign that starting with the simplest correct algorithm leaves you room to grow instead of boxing you in.

Have you shipped a "basic" algorithm like BFS or DFS in a real feature and had people be surprised it wasn't over-engineered? I'd like to hear what problem it solved for you β€” and whether you eventually outgrew it.

Top comments (0)