How Bubble fetches data

How does Bubble plan and execute queries to your database from the frontend?

Every time you load a page, a repeating group fills with rows. A text element shows your name. The counter in each row shows how many tasks you have. And it happens quickly. This is the result of a query planner and data-fetching engine that fetches data as efficiently as possible.

This lesson teaches the model that powers that.

Browser
Page
Hi, —
Queue
Bubble
Database
Dependencies fan into a queue, fetches go to the database, results fan back out to the page.

Calculating dependencies

As discussed in Breaking down a page load, the Bubble engine walks the app JSON to calculate its dependencies.

As it walks, it notes every expression that needs a database value to evaluate now, before render - Current User's name, Search for Tasks, a repeating group's data source, a text bound to a row. Each reference declares a dependency: something the page must fetch before it can render that piece.

References to the same data deduplicate. Three different elements all reading Current User's emaildon't trigger three fetches - Bubble recognises the shared dependency and resolves it once. The same is true of searches: identical constraints, same sort, same data type, one query.

Building the queue

Each unique dependency becomes an item in a fetch queue. Constraints and sort orders are baked into each query when it's built.

The engine doesn't fire each query individually. It batches them, and dispatches the batch when one of three thresholds is hit - whichever comes first:

When the batch runs, the engine combines as many of those queries as it can into a single request. You can see this in the browser's network tab as a request labelled msearch - multiple searches, one round trip.

Processing the queue

Each msearchreturns up to 20 items per search in the first response. For most pages, that's enough - the repeating group fills, the page is interactive.

When more rows are needed (say the user scrolled past the first 20, or the repeating group isn't lazy-loaded and wants 500 rows immediately), the engine works out how many to fetch next. It uses the first batch to estimate average row size and targets a 1MB response. So if 20 rows came back at 200KB, the next batch fetches 100. There's a hard ceiling of 400 rows per batch - even if your Things are tiny, the engine won't fetch them all at once.

The practical implication: smaller Things load faster. Fewer fields and less data per field means more rows per batch, fewer round trips, sooner to rendered. We'll unpack this more in a later lesson, but the takeaway is that long tables with many rows scale well in Bubble, but wide tables with lots of data per row scale less well.

Fetching by ID

Searches aren't the only way data flows from the database. Plenty of Bubble expressions fetch by ID without you ever writing a search - for example, Current cell's Invoice's Companyretrieves the Company by the ID stored on the Invoice's Company field. You never type the lookup explicitly; it happens any time you traverse a relationship.

These fetches are batched separately from searches. The engine groups up to 200 pending ID lookups into a single request, then fires.

Filtering on the client

:filtered is the operator that tries hardest to avoid hitting the database. When you call Search for Users :filtered, Bubble decides at runtime whether to evaluate the filter in the browser (free, fast) or to send it to the server (costs WU, slower).

The first check is whether every constraint in the filter canrun client-side. Most can - equals, not equal, contains, greater than, less than, is empty. A handful can't, and any one of them sends the whole filter to the server:

When all constraints can run locally, the engine then weighs whether finishing the load is cheaper than running the filter on the server. Roughly:

:filtered calledall constraintsclient-evaluable?noserver filteryesall data alreadyon page?yesfilter locallynocheap to finishloading?yesfetch rest,filter locallynoserver filteroutcomes shaded
The :filtered decision. Server filter costs WU; local filter is free.

A few list-field types always operate client-side regardless of size: lists of geographic addresses, date ranges, and number ranges. The server doesn't have an efficient way to filter those, so they come down with the rows and the work happens in the browser.

When dependencies change

The same loop re-runs whenever a referenced value changes. A user types into an input bound to a search constraint, and that search's dependency is now stale - the engine re-queues it, the database returns new rows, the affected elements re-render.

This means invisible elements can still cost you. If a hidden tab contains expressions that need to be evaluated on page load (often not a direct relationship - usually it's through distant references to other page elements), those dependencies get evaluated and queued anyway. The easiest way to resolve this is making the data source conditional, so it only runs when the group or its parent is visible.