TypeORM relations performance: 81,408 rows to 43ms

TypeORM relations performance falls apart when one findOne joins many collections. How 82KB of JSON cost 1.6GB of memory, and the split that brought it to 43ms.

By

One admin page could take a Node.js server down by being opened. It showed a single user record, and the response was 82KB of JSON. Building it took about 1.6GB of memory. The cause was a single TypeORM findOne that loaded eighteen relations at once, which is the default way most TypeORM code loads an entity with its children. This post explains why that pattern hurts TypeORM relations performance so badly, the split I used to fix it, and how I checked that the response did not change.

Eighteen relations in one findOne

The endpoint was the admin view of a user on a student job marketplace. It asked TypeORM for the user plus eighteen relations in one findOne: profile data, skills, availability slots, applications, the people who had saved the student, feedback, and more.

TypeORM's default for relations is to join. The find options documentation lists relationLoadStrategy, where "join" is the default and "query" uses separate queries. So those eighteen relations became one SQL query joining every collection at once.

Why joining collections multiplies rows

Joining one collection is fine. A user with 53 applications comes back as 53 rows, each carrying a copy of the user's columns. Join a second collection to the same user and every application row pairs with every skill row. The result is the product of the collection sizes, not their sum.

For one student with 6 feedback entries, 8 skills, 8 availability slots, 53 applications and 4 savers, the query returned 6 × 8 × 8 × 53 × 4 rows. That is 81,408 near-identical rows. TypeORM then hydrates every one of them into entities and folds the duplicates back together, which is about 1.6GB of work to produce an 82KB response. Loaded as separate queries, those five collections come to 79 rows.

This is often called a cartesian explosion. It hides well in development, where test users have one or two of everything and the product stays small. It shows up with your most active users, which are the ones an admin is most likely to open.

How one page took down the server

The admin dashboard runs under React StrictMode and fired the request twice. Two of those hydrations running together hit V8's heap limit of about 4GB, and the process aborted. Every other request on that server went down with it.

That is why I treat memory blowups from ORM queries as availability bugs, not slow-page bugs. One unlucky record can take out the whole API.

Join single-row relations, query collections separately

The fix keeps joins for relations that return at most one row, and loads every collection with its own query. The cost becomes the sum of the collection sizes again. With names simplified:

const user = await userRepo.findOne({
  where: { id },
  relations: { profile: true, address: true, feedback: true },
});

const [skills, slots, applications, savers] = await Promise.all([
  skillRepo.find({ where: { user: { id } } }),
  slotRepo.find({ where: { user: { id } } }),
  applicationRepo.find({ where: { student: { id } } }),
  saverRepo.find({ where: { student: { id } } }),
]);

feedback is a collection, and it stays in the joined set anyway. The User entity has an @AfterLoad hook that builds a derived field from it, and that hook runs before anything you attach to the entity afterwards. Load feedback separately and the hook sees an empty list. Check your entities for hooks like that before you move a relation out of the join, because the response changes without any error.

The other option is to set relationLoadStrategy: "query" on the find call and let TypeORM issue the separate queries itself. That is less code. I prefer the explicit version on hot endpoints because the queries are visible in the code, and it is easy to keep one relation joined for a reason like the hook above.

Is it better to use joins or separate queries in TypeORM?

Join relations that return one row per parent, such as a profile or an owner. Load collections with separate queries, or with relationLoadStrategy: "query", as soon as you load more than one collection at a time. One collection in a join is fine. Two or more multiply each other's row counts.

Measuring the fix and checking the output

I measured on the same student account before and after. One request went from 2.6s and 1.6GB to 43ms with no measurable heap growth. Six concurrent requests now stay up, where three used to kill the server.

Speed is only half the check. A change to how data is loaded can drop a field or reorder a list without failing anything. I compared the responses for a client, a family and two students, and they were byte-for-byte unchanged. The method is in verifying a refactor by diffing old and new output.

A related pattern on the same backend: the applicants endpoints share one query builder that selects exactly the ten columns the response uses. The five-row dashboard feed and the full applicants list come from the same builder, so they cannot drift apart. The dashboard's first paint loads five rows, never the whole feed, which can run to a few hundred. Explicit selects keep you from hydrating columns nobody reads.

TypeORM relations performance habits that save you later

  • Search for wide relation lists. Any relations array or object with several collections in it deserves a look. Admin and export endpoints are the usual suspects.
  • Log the SQL on a slow request. One query with eight LEFT JOINs is a sign. So is a row count far larger than the response suggests.
  • Test with your largest real records. A snapshot of production data finds this in minutes, where seed data never will.

If you are tuning TypeORM, the schema side has its own trap: synchronize can drop the indexes you built by hand. For dashboards that count and total in application code, see moving aggregation into Postgres. And if you are still choosing an ORM, I wrote up why I picked TypeORM over Prisma.

The short version

When one findOne joins several collections, the rows multiply, and memory goes with them. Keep joins for single-row relations, load each collection with its own query, and watch for @AfterLoad hooks that depend on a joined relation. Measure with your largest real record, and compare the response before and after so the fix changes nothing but speed.

More of my backend work is on the projects page.

Want this kind of work on your product?

Get in touch