---
title: "Eager, lazy, and batch: when should an application load data"
date: 2026-08-18T17:30:00
tags: [ruby, rails, nodejs, go, graphql, performance, frontend, backend]
description: "Eager loading in ActiveRecord, its equivalents in Node.js and Go, DataLoader in GraphQL, and lazy image loading in the browser are all answers to the same question: what should be loaded in advance, on demand, or speculatively."
embedding:
---

![Eager, lazy, and batch loading](img/eager-lazy-loading-strategies-preview.jpg)

Eager loading in Rails, `await import()` in Node.js, DataLoader in GraphQL, and `loading="lazy"` on an image look like four unrelated techniques from different layers of the stack. In reality, they are four answers to the same question: **when does the system pay for a resource that may or may not be needed**? We will examine the mechanics of each approach and then assemble a common decision-making model.

<!-- truncate -->

## What are we really paying for?

The intuition that "loading data costs as much as the data weighs" is almost always wrong. The actual bill consists of three independent types of expense.

| Resource | What is consumed | Who suffers first |
|---|---|---|
| Latency | round-trip time to the source | the user waiting for a response |
| Bandwidth | bytes over the network, memory for the result | the mobile client, the GC, the database cache |
| Source capacity | connections, workers, IOPS, API limits | all other users at the same time |

The key point is that these costs are optimized differently and often conflict. One large query saves latency, but consumes memory and can triple the number of transferred bytes. A hundred small queries save traffic, but consume the connection pool and add a hundred delays one after another.

That is why the basic quantity in loading design is not the amount of data, but the **number of sequential round trips on the critical path**:

```text
response time ≈ Σ(round trips) + Σ(source work) + Σ(application work)

100 requests × 1 ms of network time = 100 ms, even if every request returns 40 bytes
```

All further decisions grow out of this. It is useful to arrange them along three axes:

- **when** we load: in advance (eager), on demand (lazy), or speculatively based on a prediction;
- **at what granularity**: one record at a time, in a batch, or as a stream;
- **who decides**: the code author during development, the runtime during execution, or the user through their behavior.

Ruby, Node.js, Go, GraphQL, and the browser answer these three questions differently. The difference is explained not by fashion, but by the properties of each runtime.

## Ruby: lazy by default, explicitly eager when needed

ActiveRecord is a rare example of an environment where laziness is built into the object model. The `user.posts` association is not an array, but a proxy object that executes SQL only when it is accessed for the first time.

```ruby
posts = Post.limit(200)          # SQL has not run yet
posts.each do |post|
  puts post.author.name          # each call = a separate SELECT
end
```

This is the classic **N+1**: one query for the list and one query for every record. With 200 posts and 1 ms per round trip, that is 200 ms spent not on data, but on waiting. The problem is invisible in the code: `post.author.name` looks like a read from an in-memory field.

Rails offers four different tools that are often confused:

| Method | SQL | Loads into memory | Allows filtering by the association |
|---|---|---|---|
| `joins(:author)` | INNER JOIN | no | yes |
| `preload(:author)` | 2 separate queries, the second with `IN (...)` | yes | no |
| `eager_load(:author)` | 1 query with LEFT OUTER JOIN | yes | yes |
| `includes(:author)` | chooses preload or eager_load automatically | yes | yes, with `references` |

`includes` is not a "third way", but a heuristic: without conditions on the related table it behaves like `preload`, and with `references(:authors)` or `where("authors.name = ?")` it switches to `eager_load`. This leads to the famous surprise: the same line of code can execute two queries or one JOIN depending on what is added next to it.

```mermaid
flowchart TB
    Q["Post.limit(200)"]

    subgraph N1["N+1: lazy association"]
      direction TB
      A1["SELECT * FROM posts<br/>LIMIT 200"] --> A2["SELECT * FROM authors<br/>WHERE id = 1"]
      A2 --> A3["SELECT * FROM authors<br/>WHERE id = 2"]
      A3 --> A4["... 198 more queries"]
    end

    subgraph PL["preload: batch by keys"]
      direction TB
      B1["SELECT * FROM posts<br/>LIMIT 200"] --> B2["SELECT * FROM authors<br/>WHERE id IN (1, 2, ... 87)"]
    end

    subgraph EL["eager_load: one JOIN"]
      direction TB
      C1["SELECT posts.*, authors.*<br/>FROM posts<br/>LEFT OUTER JOIN authors"]
    end

    Q --> N1
    Q --> PL
    Q --> EL
```

It is important that `eager_load` is not always better than `preload`. When several collections are loaded at once, a JOIN produces a Cartesian multiplication of rows: a user with 20 posts and 30 comments is returned from the database 600 times, each time with the full set of their columns. Two or three such JOINs turn the savings from one round trip into megabytes of traffic and noticeable deduplication work on the Ruby side. The simple rule is: **JOIN is good for many-to-one associations; separate queries are better for one-to-many associations**.

Rails also provides tools to keep laziness from going unnoticed:

```ruby
# 1. Disable implicit extra loads: any access to an unloaded association
#    raises ActiveRecord::StrictLoadingViolationError (Rails 6.1+)
user = User.strict_loading.first
user.posts # => exception instead of a silent extra query

# 2. Independent queries can be sent in parallel (Rails 7.0+)
@users = User.where(active: true).load_async
@stats = Report.recent.load_async
```

`strict_loading` changes the responsibility model: previously, N+1 was a performance problem caught in production through logs or the Bullet gem; now it is an error that can fail in tests. `load_async` attacks a different axis: it does not remove queries, but it removes their sequential execution.

Another option is not to load the data at all, but to keep a derived value next to it: `counter_cache` stores `posts_count` on the user row, turning an aggregate into a field. The cheapest load is the one that never happened.

## Node.js: relationships are explicit, but the problem is the same

The Node ecosystem has almost entirely moved away from lazy proxies, and this is a deliberate choice. Prisma and Drizzle make you list relationships in the query:

```ts
// Prisma: the relationship exists only if you ask for it
const posts = await prisma.post.findMany({
  take: 200,
  include: { author: true },
  relationLoadStrategy: "join", // or "query"
});
```

`relationLoadStrategy` is exactly the choice between `eager_load` and `preload` in Rails, only exposed as an explicit parameter: `join` assembles the result in the database through a LATERAL JOIN and JSON aggregation, while `query` sends several queries and joins the data in the application. The driver has become more honest, but the trade-off remains the same: one round trip and the risk of row multiplication versus several round trips and assembly at runtime.

Sequelize and TypeORM are closer to the Ruby model and allow lazy relationships, but even there laziness is expressed through a type:

```ts
// TypeORM lazy relation: the property returns a Promise
const author = await post.author; // await inside a loop = N+1
```

The `Promise<Author>` type forces you to write `await`, which has an unexpected benefit: lazy loading can no longer hide behind what looks like a field read. But it introduces a Node-specific trap:

```ts
// sequentially: 200 round trips one after another, ~200 ms
for (const post of posts) {
  post.authorName = (await getAuthor(post.authorId)).name;
}

// in parallel: 200 requests at once, ~1 round trip in elapsed time,
// but a pool of 10 connections will queue the rest
await Promise.all(posts.map(p => getAuthor(p.authorId)));
```

A single-threaded event loop makes concurrent I/O almost free in terms of CPU, so `Promise.all` looks like a fix. In reality, it moves the queue from the application into the connection pool and the database: 200 concurrent requests with a pool of 10 connections are still 20 waves of waiting, plus the risk of exhausting the pool for all other requests in the process. Concurrency without batching does not solve N+1; it pushes the problem down one layer.

## Go: no magic, only explicit code

In Go, the usual combination is `pgx` with `sqlc` or handwritten SQL, where associations and lazy proxies simply do not exist. That is why N+1 in Go looks like N+1 in the code: a loop with a query inside it is visible to anyone reviewing it.

The natural solution is batching by keys, which Rails hides inside `preload`:

```go
// collect keys, make one request, distribute the result through a map
ids := make([]int64, 0, len(posts))
for _, p := range posts {
    ids = append(ids, p.AuthorID)
}

rows, err := db.Query(ctx,
    `SELECT id, name FROM authors WHERE id = ANY($1)`, ids)

authors := map[int64]Author{} // index for joining in memory
```

GORM repeats the Ruby dichotomy almost word for word: `Preload("Author")` sends a second query with `IN`, while `Joins("Author")` makes one query with a JOIN. For parallel independent loads, use `errgroup`, which also limits concurrency:

```go
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // explicit ceiling instead of an unbounded Promise.all
g.Go(func() error { return loadUsers(ctx) })
g.Go(func() error { return loadStats(ctx) })
err := g.Wait()
```

Another tool that is not part of typical ORM thinking is `singleflight`: if a hundred goroutines request the same key at the same time, only one request reaches the source and all one hundred receive the result. This is neither eager nor lazy, but deduplication over time - a remedy for a cache stampede, when an expired cache key instantly creates an avalanche of identical requests.

| Property | Ruby / ActiveRecord | Node / Prisma, TypeORM | Go / sqlc, GORM |
|---|---|---|---|
| Default behavior | lazy and implicit | explicitly list relationships | nothing happens automatically |
| Where N+1 is visible | in SQL logs | in code if the relationship is lazy | always in code |
| Choosing JOIN or separate queries | `eager_load` / `preload` | `relationLoadStrategy` | `Joins` / `Preload` or manually |
| Parallel queries | `load_async`, threads | `Promise.all`, event loop | goroutines, `errgroup` |
| Cost of convenience | hidden queries | schema as the source of truth | more code for assembly |

The difference between ecosystems is primarily a difference in **how expensive it is not to notice an extra round trip**. Rails optimizes for writing speed and requires discipline (`strict_loading`, Bullet); Go optimizes for predictability and requires patience.

## DataLoader: batching without knowing the query shape

Everything described above assumes that the code author knows which data will be needed. GraphQL breaks this assumption: the client defines the response shape, and the same resolver can be called in a context that was unknown when the resolver was written.

```graphql
query {
  posts(limit: 200) {       # 1 request
    author { name }          # 200 author resolver calls
  }
}
```

The `author` resolver physically cannot perform eager loading: it is called once for each post and knows nothing about its neighbors. [DataLoader](https://github.com/graphql/dataloader) solves this by moving batching from code-writing time to runtime:

1. the resolver does not go to the database, but puts the key in a queue and receives a promise;
2. the runtime lets all resolvers run;
3. at the end of the execution step, the accumulated keys are sent in one `WHERE id IN (...)` query;
4. the promises are resolved, and duplicate keys are served from the request cache.

```mermaid
sequenceDiagram
    participant R as Resolvers (200)
    participant DL as DataLoader
    participant DB as Database

    R->>DL: load(1), load(2), load(2), ... load(87)
    Note over DL: accumulate keys<br/>deduplicate 200 → 87
    DL->>DB: SELECT * FROM authors WHERE id IN (87 keys)
    DB-->>DL: 87 rows
    Note over DL: map results strictly by key order
    DL-->>R: 200 promises resolve
```

The moment at which the "step ends" is implemented differently in each environment, and this is the most interesting difference:

| Environment | Batching mechanism | Window boundaries |
|---|---|---|
| Node.js | microtasks and `process.nextTick` | the end of the current event-loop tick |
| Ruby (`GraphQL::Dataloader`) | Fiber: the resolver is suspended while the scheduler runs sources | when all fibers have reached a wait |
| Go (`dataloaden`, `dataloadgen`) | a wait timer or a batch-size limit | usually a 1-16 ms window or N keys |

Node and Ruby use a natural synchronization point provided by their runtime. Go has no such point: goroutines do not "finish a tick", so the batch is closed by a timeout. This adds fixed latency to every batch and turns the window choice into a trade-off between latency and batch size.

There are two DataLoader contracts that are violated most often:

- **result order and length.** The batch function must return exactly as many elements as there were keys, in the same order. A database does not guarantee the order of `IN (...)` and silently omits missing rows, so the result almost always needs to be distributed through a map, with missing values filled with `null` or an error;
- **cache lifetime.** DataLoader is created **for every request**. A cache that survives the request is not an optimization, but a data leak between users: a cached entity can bypass the next request's permission check and return a stale value after a write.

The essential difference between DataLoader and eager loading is:

| | Eager loading | DataLoader |
|---|---|---|
| Who decides | the code author in advance | the runtime based on actual accesses |
| What it knows about needed data | the complete query shape | only the keys accumulated during the window |
| Number of queries | 1 per relationship | 1 per relationship and tree level |
| Unnecessary data | loads what may not be needed | loads exactly what was requested |
| Main risk | over-fetching and JOIN multiplication | a forgotten loader, a cache outside the request |

The most precise description of DataLoader is: **lazy loading with forced batching**. It does not cancel laziness; it fixes its main side effect - a multitude of small round trips. That is why these techniques do not compete: at the top level of a tree, it is sensible to use eager loading for relationships that are almost certainly needed, and to give anything dependent on the query shape to a loader. I wrote about how this fits with federation and subscriptions in the articles about the [journey to a federated GraphQL](/en/blog/tech/journey-to-a-federated-graphql/) and [scalable GraphQL subscriptions](/en/blog/tech/dream-of-scalable-enriched-graphql-subscriptions/).

## The browser: the same principle, different economics

Lazy image loading looks like a distant relative, but it differs in only three parameters: what is scarce, who predicts the need, and what an error costs.

| | Data on the server | Resources in the browser |
|---|---|---|
| Scarce resource | database connections, source capacity | the user's connection, main thread, tab memory |
| Predictor of need | resolver code | viewport and user behavior |
| Cost of unnecessary loading | load on the shared database | the particular person's traffic and battery |
| Cost of loading too late | a slow API response | empty space on the screen, layout shift |

This leads to practical consequences that are regularly ignored.

**Lazy by default means below the fold only.** The `loading="lazy"` attribute postpones the request until the image approaches the viewport. The browser chooses the threshold itself, taking connection quality into account. For the main image on the screen this is actively harmful: it is almost always the LCP element, and laziness adds an entire extra resource-discovery round trip.

```html
<!-- hero image: load as early as possible and with high priority -->
<img src="/hero.avif" fetchpriority="high" width="1200" height="630" alt="">

<!-- everything below the fold: defer it and reserve the space -->
<img src="/gallery-14.avif" loading="lazy" decoding="async"
     width="800" height="600" alt="">
```

**Reserve space eagerly even when the bytes load lazily.** The `width`/`height` attributes (or `aspect-ratio`) are needed precisely because loading is deferred: without them, an image that arrives late shifts the layout. This is a general principle that also works on the backend: **data can be deferred, but the response structure cannot**. Skeletons, placeholders, and blurred previews solve the same problem - show the shape before the content.

**Laziness creates cascades.** The most expensive scenario looks like this: the HTML loads a JS bundle, the bundle hydrates and requests data, the data returns an image URL, and only then does the browser start downloading the image. Four sequential round trips instead of one are the same N+1, only distributed across layers. The same techniques fix it: hints (`preload`, `modulepreload`), combining responses (the server returns data together with the HTML), and streaming.

**Not only the network, but rendering can be lazy too.** `content-visibility: auto` together with `contain-intrinsic-size` allows the browser to skip layout and painting for invisible blocks, saving main-thread work without a single network request. Splitting bundles by route (`React.lazy`, dynamic `import()`) does the same for JavaScript.

On the server, the direct equivalent is [Turbo Frames](/en/blog/tech/frontend/turbo-frames-s-nulya/) with `loading="lazy"`, which postpones the request for an HTML fragment until the frame appears in the viewport. Lazy loading data, markup, and pixels is the same idea at different heights of the stack.

## Speculation: paying in advance based on a user signal

There is a fourth strategy that is neither eager nor lazy: load something that has not been requested yet, but is likely to be needed.

Here, behavior becomes the predictor. Hovering over a link and staying there for a moment is a reasonable signal of intent, and the Speculation Rules API formalizes this through levels of "eagerness":

```html
<script type="speculationrules">
{
  "prerender": [{
    "where": { "href_matches": "/en/blog/*" },
    "eagerness": "moderate"
  }]
}
</script>
```

`moderate` starts preloading after roughly 200 ms of hovering, `conservative` does so only when the mouse button is pressed, and `immediate` starts at once. Support is currently concentrated in Chromium-based browsers; Safari is adding it gradually, and Firefox does not have it yet. It is therefore an optimization, not a mechanism to build a system around.

The same technique applies to an infinite feed: it is sensible to request the next page not when the user has reached the end of the list, but when a couple of screens remain. That is what makes infinite scroll smooth instead of jerky.

Speculation has its own, unusual cost of error:

- traffic and battery are spent on pages the user will never open, so respecting `Save-Data` and battery-saving modes is mandatory;
- prerendering actually executes JavaScript, so analytics that do not check `document.prerendering` will start counting page views that never happened;
- only safe GET navigations can be prefetched: a "log out" or "add to cart" link, or any URL with side effects, must be explicitly excluded.

On the backend, the same principle is called cache warming and `stale-while-revalidate`: return a slightly stale value immediately and refresh it in the background. That is also a form of paying in advance.

## A fifth option: do not choose, deliver in parts

Sometimes the right answer to "early or late" is "both". Streaming breaks the connection between the beginning and the completeness of a response:

- streaming SSR and `Suspense` return the page skeleton immediately, then deliver slow blocks as they become ready;
- the `@defer` and `@stream` directives in GraphQL allow the client to mark expensive fields as non-critical;
- cursor pagination removes the question entirely by returning exactly one screen of data;
- Turbo Streams and WebSocket deliver what did not fit into the first response.

Streaming is valuable because it optimizes not the total time, but **the time to the first useful pixel**, which is what the user perceives as speed.

## How to choose

Let us reduce everything to one estimate. Let `P` be the probability that a resource will be needed, `C` the cost of loading it, and `L` the delay the user will see if it is loaded when needed.

**`lazy`**. Expected cost: `P × (C + L)`. We pay only when the resource is accessed.

**`eager`**. Expected cost: `C`. Latency: `Δt = 0`, but we always pay.

**`speculative`**. Expected cost: `C`. Latency: `Δt ≈ 0`; cost of error: `(1 − P) × C`.

**`batch`**. For `N` resources, `L_batch ≈ L`: one round trip instead of `N`.

This gives us practical rules:

- when `P` is close to one, laziness is pointless: it adds a round trip and saves nothing (the post author, avatar, access rights);
- when `P` is small and `C` is large, laziness is mandatory (attachments, history, expensive aggregates);
- when `P` is unknown in advance because the client defines the query shape, batching is needed: it reduces `L` without requiring knowledge of `P`;
- speculation is justified only when there is an external signal that raises `P` (hover, scroll position, typical route);
- if `C` is large for any reason, the best move is to reduce `C` itself: pagination, column projection, or a denormalized counter.

```mermaid
flowchart TB
    START["The resource may be needed"] --> P1{"Is it needed almost always?"}
    P1 -->|yes| EAGER["Eager loading<br/>preload / include / Preload"]
    P1 -->|no| P2{"Does the client<br/>define the query shape?"}
    P2 -->|yes| DL["DataLoader<br/>lazy batch per request"]
    P2 -->|no| P3{"Is there an intent signal?<br/>hover, scroll, route"}
    P3 -->|yes| SPEC["Prefetch / prerender<br/>cache warming"]
    P3 -->|no| P4{"Can the response<br/>be delivered in parts?"}
    P4 -->|yes| STREAM["Streaming, @defer,<br/>pagination"]
    P4 -->|no| LAZY["Lazy loading<br/>when accessed"]
```

And here is the main observation that made this whole breakdown worthwhile: **N+1 is fractal**. It appears at every level where the system makes a round trip to something external:

| Layer | What N+1 looks like | How to fix it |
|---|---|---|
| SQL | a query inside a loop over records | preload, `IN (...)`, JOIN |
| HTTP API | a call for every item in a list | a batch endpoint, `?ids=` |
| GraphQL | a resolver for every node | DataLoader |
| Microservices | a chain of synchronous calls | aggregation, denormalization, events |
| Browser | an HTML → JS → data → image cascade | preload, return data with the HTML |
| LLM agents | one tool call per entity | batch tools, parallel calls |

The tools in each row are different, but the question is the same: how many times do we cross the process boundary, and can those trips be combined? The practical conclusion for any layer fits into four points: **always batch, cache strictly within the request boundary, eagerly load only what is almost certainly needed, and speculate only on an explicit user signal.**
