Saltar a contenido

Capítulo 11 — Consultas avanzadas en Cloud Firestore

Recursos visuales propuestos

Antes de desarrollar este capítulo conviene elegir con cuidado los recursos visuales que realmente mejoren el aprendizaje. En consultas avanzadas, muchos conceptos iniciales —como filtros, ordenamientos y paginación— se entienden mejor con comparaciones visuales y secuencias simples. Los diagramas SVG deben reservarse para mostrar mecanismos internos del motor de consultas, flujo entre cliente e infraestructura y relación entre índices y ejecución.

Imágenes didácticas

  1. Ejemplos de filtros. Se recomiendan como imagen didáctica porque ayudan a visualizar cómo una colección se reduce progresivamente cuando se aplican condiciones where().
  2. Comparación entre distintos tipos de consultas. Conviene como imagen didáctica porque facilita contrastar consulta puntual por ID, consulta por igualdad, consulta por rango y consulta compuesta.[cite:1]
  3. Funcionamiento visual de la paginación. Es mejor como imagen didáctica porque permite mostrar páginas, cursores y posición relativa de los resultados de una forma muy intuitiva.[cite:1][cite:2]
  4. Ejemplo de Collection Group. También encaja como imagen didáctica porque muestra cómo una consulta atraviesa subcolecciones con el mismo nombre en distintos documentos padre.
  5. Flujo de una consulta sencilla. Conviene como imagen didáctica para introducir el recorrido general de una consulta sin necesidad de entrar todavía en detalle técnico exhaustivo.

Diagramas SVG

  1. Funcionamiento interno del motor de consultas. Aquí sí conviene SVG porque hay que representar índices, filtros, ordenamientos implícitos y recuperación de documentos con mayor precisión técnica.[cite:1][cite:2]
  2. Flujo de ejecución de una consulta desde el cliente hasta Firestore. Se recomienda SVG porque involucra aplicación, SDK modular, red, backend e índices.
  3. Arquitectura de consultas en tiempo real. Debe ser SVG porque necesita mostrar el ciclo de suscripción, actualización del resultado y nuevos eventos de lectura.[cite:2]
  4. Relación entre índices y consultas. Conviene SVG porque es un tema estructural interno donde una vista escalable y editable aporta más valor que una imagen simple.[cite:2]

Objetivos de aprendizaje

Al finalizar este capítulo, el lector será capaz de:

  • Comprender cómo funciona el motor de consultas de Firestore y por qué depende fuertemente de índices y restricciones estructurales.[cite:1][cite:2]
  • Construir consultas simples y compuestas utilizando exclusivamente la sintaxis modular moderna del SDK de Firebase para web.[cite:1]
  • Aplicar filtros por igualdad, rango, arrays y conjuntos (in, not-in, array-contains, array-contains-any) con criterio técnico.
  • Ordenar, limitar y paginar resultados utilizando orderBy(), limit(), limitToLast() y cursores como startAfter() y endBefore().[cite:1][cite:2]
  • Consultar subcolecciones y usar Collection Group Queries en escenarios reales del proyecto del libro.
  • Diferenciar entre consultas puntuales con getDocs() y consultas reactivas con onSnapshot(), entendiendo cuándo conviene cada una y cuál es su impacto económico.[cite:2]
  • Optimizar búsquedas para reducir lecturas, latencia y costo operativo en aplicaciones profesionales.[cite:2]

Introducción

A medida que una aplicación crece, el desafío principal ya no es solo guardar datos, sino recuperarlos de forma precisa, rápida y económica. En Firestore, consultar datos no consiste simplemente en “filtrar una colección”. Significa construir una búsqueda que el motor pueda resolver usando índices, respetando reglas internas y devolviendo exactamente los documentos que la interfaz necesita.

La documentación oficial explica que Firestore permite ordenar y limitar datos con orderBy() y limit(), y que las consultas pueden utilizarse tanto con lecturas puntuales como con listeners en tiempo real.[cite:1] También indica limitaciones importantes: por ejemplo, si se usa un filtro de rango, el primer ordenamiento debe realizarse sobre ese mismo campo, y cualquier orderBy() excluye documentos donde ese campo no exista.[cite:1]

A esto se suma la dimensión económica. La documentación de precios aclara que Firestore cobra por los documentos leídos, escritos y eliminados, pero también por entradas de índice leídas para resolver ciertas consultas.[cite:2] Además, toda consulta tiene al menos un costo mínimo de una lectura, incluso si no devuelve resultados.[cite:2] Por eso, consultar bien en Firestore no es solo una habilidad técnica. Es una competencia arquitectónica.

En este capítulo se estudiarán consultas avanzadas usando exclusivamente la sintaxis modular moderna del SDK web. Cada patrón se aplicará al proyecto oficial del libro: usuarios, instituciones, docentes, alumnos, grupos, cursos, clases, tareas, evidencias, archivos y notificaciones. El objetivo es que el lector no memorice recetas, sino que comprenda por qué una consulta funciona, por qué a veces no funciona y qué impacto tiene cada decisión.

Desarrollo completo

¿Cómo funciona el motor de consultas de Firestore?

Firestore no ejecuta consultas como una base relacional que recorre tablas y arma joins dinámicos sobre la marcha. Su motor está diseñado alrededor de documentos e índices. Cuando se define una consulta, Firestore intenta resolverla a partir de estructuras indexadas y devolver los documentos que cumplen las restricciones.[cite:1][cite:2]

La documentación de precios aporta una pista clave sobre ese funcionamiento interno: una consulta puede generar cargos no solo por documentos leídos, sino también por entradas de índice leídas para satisfacerla.[cite:2] Eso significa que el trabajo interno del motor no consiste únicamente en abrir documentos y revisarlos uno por uno. En realidad, gran parte del procesamiento ocurre sobre índices que permiten localizar rápidamente los candidatos.

Este diseño hace que Firestore sea muy eficiente en búsquedas compatibles con su modelo, pero también explica sus restricciones. Cuando una consulta no puede resolverse con la estructura indexada disponible o combina operadores de forma incompatible, Firestore no “hace magia”. Rechaza la consulta o exige un índice adicional.

Lecturas de documentos

La forma más simple de recuperar información es leer un documento por su ruta exacta. En ese caso no se construye una búsqueda en colección, sino una lectura puntual.

import { doc, getDoc } from 'firebase/firestore';

async function obtenerUsuario(uid) {
  const usuarioRef = doc(db, 'usuarios', uid);
  const snap = await getDoc(usuarioRef);

  if (!snap.exists()) return null;

  return { id: snap.id, ...snap.data() };
}

Este tipo de lectura suele ser el más directo y, en general, uno de los más predecibles en costo. Según la documentación de precios, una lectura puntual de un documento pequeño consume una lectura de documento.[cite:2] Por eso, cuando la aplicación ya conoce el ID exacto, buscar por colección suele ser innecesario.

Consultas simples

Una consulta simple es aquella que filtra o recupera documentos de una colección sin una combinación compleja de múltiples condiciones. Puede ser una consulta por igualdad, una consulta con límite o una consulta ordenada elemental.

En el SDK modular, las consultas se construyen con query() y restricciones como where(), orderBy() o limit().

import { collection, query, where, getDocs } from 'firebase/firestore';

async function obtenerDocentesActivos() {
  const usuariosRef = collection(db, 'usuarios');
  const q = query(
    usuariosRef,
    where('rol', '==', 'docente'),
    where('activo', '==', true)
  );

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Aunque este ejemplo ya combina dos filtros, sigue siendo conceptualmente simple: parte de una colección raíz y aplica condiciones directas sobre campos concretos.

Consultas por ID

En Firestore, la consulta por ID suele resolverse mejor con una lectura puntual a través de doc() y getDoc() cuando se conoce la ruta exacta. Sin embargo, también existen escenarios donde se desea filtrar por el nombre del documento dentro de una consulta más amplia.

La documentación de precios recuerda que el campo especial __name__ se considera siempre un campo de rango en ciertos contextos de consulta.[cite:2] Esto es importante porque filtrar por nombre de documento puede tener implicaciones distintas a las de un campo ordinario.

En la práctica, para el proyecto del libro, si se conoce usuarios/{uid}, conviene leer directamente el documento. Si se necesita cruzar varios IDs dentro de una colección, puede estudiarse una consulta por conjunto, pero siempre evaluando costo y restricciones.

Consultas por igualdad

Las consultas por igualdad son de las más comunes y claras. Se construyen con where(campo, '==', valor).

import { collection, query, where, getDocs } from 'firebase/firestore';

async function obtenerCursosPorInstitucion(institucionId) {
  const cursosRef = collection(db, 'cursos');
  const q = query(cursosRef, where('institucionId', '==', institucionId));

  const snapshot = await getDocs(q);
  return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
}

Estas consultas son especialmente útiles para paneles administrativos, filtros por institución, rol, estado o tipo. También suelen ser una de las mejores formas de explotar un modelo bien diseñado: si el documento ya contiene los campos de filtrado correctos, la consulta se mantiene simple y eficiente.

Consultas por rango

Las consultas por rango utilizan operadores como <, <=, >, >=. La documentación de ordenamiento y límites indica una restricción clave: si una consulta tiene un filtro de rango, el primer orderBy() debe aplicarse al mismo campo del rango.[cite:1]

import { collection, query, where, orderBy, getDocs } from 'firebase/firestore';

async function obtenerTareasConEntregaFutura(fechaActual) {
  const tareasRef = collection(db, 'tareas');
  const q = query(
    tareasRef,
    where('fechaEntrega', '>=', fechaActual),
    orderBy('fechaEntrega')
  );

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Esta regla no es opcional. Firestore la exige porque forma parte de la manera en que procesa consultas indexadas.[cite:1]

Operadores de comparación

Los operadores de comparación más frecuentes en Firestore son:

  • ==
  • <
  • <=
  • >
  • >=
  • !=
  • in
  • not-in
  • array-contains
  • array-contains-any

Cada uno permite construir filtros distintos, pero no todos pueden combinarse libremente. La arquitectura de Firestore impone límites específicos y algunos de ellos aparecen precisamente porque el motor debe mantener consultas resolubles a través de índices.

Operadores lógicos

En la práctica cotidiana, muchas consultas combinan múltiples restricciones. Firestore permite construir consultas compuestas mediante varias cláusulas dentro de query(). En la documentación y en el comportamiento moderno del SDK, esto incluye la combinación de filtros que conceptualmente actúan como AND.

const q = query(
  collection(db, 'usuarios'),
  where('rol', '==', 'alumno'),
  where('activo', '==', true),
  where('institucionId', '==', 'inst_centro_digital')
);

Este patrón expresa una conjunción lógica: el documento debe cumplir todas las condiciones. En diseño profesional, conviene recordar que una consulta con muchos filtros no necesariamente es mejor; solo es mejor si responde exactamente a la necesidad de la interfaz y evita trabajo adicional del cliente.

where()

where() es la función central de filtrado. Define restricciones sobre un campo y un operador.

import { collection, query, where, getDocs } from 'firebase/firestore';

async function obtenerEvidenciasPendientes(cursoId) {
  const evidenciasRef = collection(db, 'evidencias');
  const q = query(
    evidenciasRef,
    where('cursoId', '==', cursoId),
    where('estado', '==', 'pendiente')
  );

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Desde el punto de vista del lector, puede parecer que where() simplemente filtra objetos. En realidad, es una instrucción para que Firestore recupere resultados compatibles con esa restricción a partir de sus índices y de la estructura de la consulta.[cite:2]

orderBy()

La documentación oficial explica que, por defecto, una consulta recupera documentos en orden ascendente por ID de documento, pero que se puede especificar el orden con orderBy().[cite:1]

import { collection, query, orderBy, getDocs } from 'firebase/firestore';

async function obtenerCursosOrdenadosPorTitulo() {
  const cursosRef = collection(db, 'cursos');
  const q = query(cursosRef, orderBy('titulo'));

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

La misma documentación advierte que orderBy() también filtra por existencia del campo: los documentos que no tengan ese campo no aparecerán en el resultado.[cite:1] Esto es crítico. Ordenar por fechaEntrega excluye automáticamente documentos sin fechaEntrega.

limit()

limit() restringe el número de resultados devueltos y el valor debe ser mayor o igual a cero.[cite:1]

import { collection, query, orderBy, limit, getDocs } from 'firebase/firestore';

async function obtenerUltimosCursosPublicados() {
  const cursosRef = collection(db, 'cursos');
  const q = query(
    cursosRef,
    orderBy('createdAt', 'desc'),
    limit(5)
  );

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Desde la perspectiva económica, limit() es una gran herramienta. La documentación de precios señala que limitar resultados puede ahorrar dinero porque permite leer solo los documentos realmente necesarios.[cite:2]

limitToLast()

limitToLast() permite recuperar los últimos resultados respecto al orden definido. Conceptualmente, es útil cuando se quiere “la última página” de una secuencia ordenada.

Su uso exige especial cuidado con el ordenamiento, porque Firestore necesita saber cuál es el criterio que define esos “últimos” elementos.

import { collection, query, orderBy, limitToLast, getDocs } from 'firebase/firestore';

async function obtenerUltimosAlumnosPorNombre() {
  const alumnosRef = collection(db, 'alumnos');
  const q = query(
    alumnosRef,
    orderBy('nombreCompleto'),
    limitToLast(10)
  );

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Este método resulta útil, pero suele usarse menos que limit() en interfaces comunes.

startAt()

Los cursores permiten paginar o continuar una consulta desde un punto específico. startAt() incluye el documento o valor indicado como punto inicial.

import { query, orderBy, startAt, getDocs, collection } from 'firebase/firestore';

async function obtenerCursosDesdeTitulo(tituloInicial) {
  const cursosRef = collection(db, 'cursos');
  const q = query(cursosRef, orderBy('titulo'), startAt(tituloInicial));

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

El uso correcto de cursores evita tener que recurrir a offsets. Eso es importante porque la documentación de precios indica que los offsets generan cargos por cada documento omitido, mientras que los cursores ayudan a ahorrar dinero leyendo solo lo necesario.[cite:2]

startAfter()

startAfter() funciona de manera parecida, pero excluye el punto de inicio y comienza justo después de él. Es la herramienta más habitual para paginación incremental.

import { collection, query, orderBy, limit, startAfter, getDocs } from 'firebase/firestore';

async function obtenerPaginaSiguienteAlumnos(ultimoDoc) {
  const alumnosRef = collection(db, 'alumnos');
  const q = query(
    alumnosRef,
    orderBy('nombreCompleto'),
    limit(20),
    startAfter(ultimoDoc)
  );

  const snapshot = await getDocs(q);
  return snapshot;
}

En aplicaciones profesionales, la estrategia normal es guardar el último documento de la página actual y usarlo como cursor para la siguiente.

endAt()

endAt() define un punto final inclusivo. Sirve para cortar el rango hasta un valor o documento específico.

import { collection, query, orderBy, endAt, getDocs } from 'firebase/firestore';

async function obtenerCursosHastaTitulo(tituloFinal) {
  const cursosRef = collection(db, 'cursos');
  const q = query(cursosRef, orderBy('titulo'), endAt(tituloFinal));

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

endBefore()

endBefore() actúa como límite final exclusivo. Su lógica es análoga a startAfter(), pero hacia el extremo final de la consulta.

import { collection, query, orderBy, endBefore, getDocs } from 'firebase/firestore';

async function obtenerCursosAntesDeTitulo(tituloFinal) {
  const cursosRef = collection(db, 'cursos');
  const q = query(cursosRef, orderBy('titulo'), endBefore(tituloFinal));

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Paginación

La paginación en Firestore debe construirse preferiblemente con cursores y no con offsets. La documentación de precios es explícita: un offset cobra una lectura por cada documento omitido, mientras que cursores, límites y page tokens no añaden ese tipo de costo y pueden ahorrar dinero.[cite:2]

Ejemplo de paginación de alumnos del proyecto:

import {
  collection,
  query,
  where,
  orderBy,
  limit,
  startAfter,
  getDocs
} from 'firebase/firestore';

async function obtenerPaginaAlumnos({ institucionId, ultimoDoc = null }) {
  const alumnosRef = collection(db, 'alumnos');

  const restricciones = [
    where('institucionId', '==', institucionId),
    orderBy('nombreCompleto'),
    limit(25)
  ];

  if (ultimoDoc) {
    restricciones.push(startAfter(ultimoDoc));
  }

  const q = query(alumnosRef, ...restricciones);
  const snapshot = await getDocs(q);

  return {
    items: snapshot.docs.map(d => ({ id: d.id, ...d.data() })),
    lastDoc: snapshot.docs[snapshot.docs.length - 1] ?? null
  };
}

Esta estrategia es escalable, económica y coherente con el diseño del motor de consultas.[cite:2]

Consultas sobre arrays

Firestore permite consultar arrays mediante operadores específicos. Esto es muy útil cuando un documento contiene listas pequeñas y estables, como etiquetas, grados, miembros visibles o roles secundarios.

Sin embargo, este tipo de consultas solo es razonable si el uso de arrays fue correcto desde el modelado. Si el array es gigantesco o crece sin control, el problema no es la consulta: es el diseño de datos.

array-contains

Permite recuperar documentos cuyo array contiene un valor determinado.

import { collection, query, where, getDocs } from 'firebase/firestore';

async function obtenerCursosPorGrupo(grupoId) {
  const cursosRef = collection(db, 'cursos');
  const q = query(cursosRef, where('gruposIds', 'array-contains', grupoId));

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Este patrón puede ser útil cuando un curso expone un pequeño resumen de grupos asociados. Si la relación es muy grande o compleja, probablemente merece otra estructura documental.

array-contains-any

Permite recuperar documentos cuyo array contiene cualquiera de varios valores.

import { collection, query, where, getDocs } from 'firebase/firestore';

async function obtenerUsuariosPorEtiquetas(etiquetas) {
  const usuariosRef = collection(db, 'usuarios');
  const q = query(
    usuariosRef,
    where('tags', 'array-contains-any', etiquetas)
  );

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Es una herramienta poderosa, pero debe usarse con sentido funcional. Si la interfaz solo necesita coincidencias por una etiqueta, array-contains es más específico.

in

in compara un campo contra un conjunto de valores posibles.

import { collection, query, where, getDocs } from 'firebase/firestore';

async function obtenerTareasPorEstado() {
  const tareasRef = collection(db, 'tareas');
  const q = query(
    tareasRef,
    where('estado', 'in', ['borrador', 'publicada', 'cerrada'])
  );

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Esto resulta ideal para paneles que agrupan varios estados equivalentes o vistas administrativas donde la categoría funcional no coincide con un único valor exacto.

not-in

not-in devuelve documentos cuyo campo no coincide con un conjunto de valores dado.

import { collection, query, where, getDocs } from 'firebase/firestore';

async function obtenerCursosNoArchivados() {
  const cursosRef = collection(db, 'cursos');
  const q = query(cursosRef, where('estado', 'not-in', ['archivado', 'eliminado']));

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Este tipo de consulta puede ser muy útil para filtrar resultados “activos” sin codificar todos los estados permitidos explícitamente. Aun así, conviene usarlo con prudencia y comprobar si semánticamente expresa mejor la intención que una consulta positiva.

Consultas compuestas

Una consulta compuesta combina filtros, ordenamientos y límites. La documentación oficial muestra un ejemplo muy representativo: un filtro de umbral combinado con orderBy() y limit().[cite:1]

import { collection, query, where, orderBy, limit, getDocs } from 'firebase/firestore';

async function obtenerTareasActivasPorFecha(cursoId, fechaDesde) {
  const tareasRef = collection(db, 'tareas');
  const q = query(
    tareasRef,
    where('cursoId', '==', cursoId),
    where('fechaEntrega', '>=', fechaDesde),
    orderBy('fechaEntrega'),
    limit(10)
  );

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Aquí la consulta no solo filtra. También ordena y limita. Ese patrón será muy frecuente en aplicaciones reales, especialmente en listados, paneles y tableros administrativos.

Restricciones del motor de consultas

Firestore tiene restricciones que no son accidentes del API, sino consecuencia directa de su modelo indexado. Algunas de las más importantes documentadas oficialmente son estas:

  • Si hay una comparación de rango, el primer orderBy() debe usar ese mismo campo.[cite:1]
  • orderBy() también filtra por existencia del campo; documentos sin ese campo no aparecen.[cite:1]
  • Una desigualdad sobre un campo implica un ordenamiento implícito por ese campo, con el mismo efecto de existencia.[cite:1]
  • El campo __name__ siempre se considera de rango en ciertos escenarios de facturación y planificación de consulta.[cite:2]

Estas restricciones tienen una consecuencia práctica muy importante: el diseño del documento y la consistencia de campos influyen directamente sobre la viabilidad de la consulta.

Consultas sobre subcolecciones

Una subcolección se consulta igual que cualquier otra colección, siempre que se conozca la ruta padre.

import { collection, query, orderBy, getDocs } from 'firebase/firestore';

async function obtenerClasesDeCurso(cursoId) {
  const clasesRef = collection(db, 'cursos', cursoId, 'clases');
  const q = query(clasesRef, orderBy('fechaProgramada'));

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Este tipo de consulta es perfecto cuando la navegación principal ya conoce el documento padre. Por ejemplo, desde la pantalla de detalle de un curso tiene mucho sentido consultar cursos/{cursoId}/clases.

Collection Group Queries

Las Collection Group Queries permiten consultar todas las subcolecciones con el mismo nombre en toda la base, independientemente del documento padre. Este recurso es extremadamente valioso cuando una entidad vive jerárquicamente pero se necesita una vista transversal.

Pensemos en el proyecto del libro. Si cada curso tiene una subcolección clases, una consulta de grupo permitiría recuperar clases de todos los cursos sin tener que recorrer curso por curso.

import { collectionGroup, query, where, getDocs } from 'firebase/firestore';

async function obtenerClasesPublicadasDeTodosLosCursos() {
  const clasesGroupRef = collectionGroup(db, 'clases');
  const q = query(clasesGroupRef, where('publicada', '==', true));

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Este patrón es especialmente útil para paneles globales, dashboards docentes o sistemas de monitoreo administrativo.

Consultas en tiempo real con onSnapshot()

Firestore permite escuchar cambios en documentos o consultas en tiempo real mediante onSnapshot(). La documentación de ordenamiento y límites recuerda que las consultas pueden usarse tanto con lecturas puntuales como con listeners.[cite:1]

import { collection, query, where, onSnapshot } from 'firebase/firestore';

function escucharNotificacionesUsuario(uid, onData, onError) {
  const notificacionesRef = collection(db, 'usuarios', uid, 'notificaciones');
  const q = query(notificacionesRef, where('leida', '==', false));

  return onSnapshot(
    q,
    snapshot => {
      const items = snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
      onData(items);
    },
    error => {
      onError(error);
    }
  );
}

Este patrón es excelente cuando la experiencia del usuario se beneficia de actualizaciones inmediatas: notificaciones, paneles en vivo, cambios de estado o monitoreo operativo.

Diferencias entre getDocs() y onSnapshot()

getDocs() realiza una lectura puntual. Recupera el resultado una vez y termina.

onSnapshot() establece una suscripción. Inicialmente obtiene resultados y luego sigue recibiendo cambios compatibles con la consulta.

La diferencia funcional es evidente, pero la económica también es importante. La documentación de precios explica que, al escuchar los resultados de una consulta, se cobra una lectura cada vez que un documento del conjunto se agrega o actualiza, y también cuando sale del conjunto por un cambio; además, el comportamiento de facturación depende de la persistencia offline y del tiempo de desconexión.[cite:2]

En resumen:

  • getDocs() conviene para cargas puntuales, paneles no reactivos y búsquedas bajo demanda.
  • onSnapshot() conviene cuando el tiempo real aporta valor real a la interfaz.

Optimización de consultas

Optimizar consultas en Firestore significa reducir el trabajo innecesario del motor, leer menos documentos, limitar mejor los resultados y construir búsquedas que el sistema pueda resolver con índices adecuados.

Buenas estrategias de optimización:

  • Leer por ID cuando el ID ya se conoce.
  • Usar limit() siempre que la pantalla no necesite el conjunto completo.[cite:1][cite:2]
  • Preferir cursores sobre offsets para paginación.[cite:2]
  • Diseñar documentos con campos de filtro bien definidos y consistentes.
  • Evitar consultas ambiguas que luego requieran filtrado adicional en el cliente.
  • Distinguir claramente entre vistas de resumen y vistas de detalle.
  • Usar tiempo real solo en las áreas donde el beneficio funcional justifica el costo adicional.[cite:2]

La optimización no empieza en el código de consulta. Empieza en el modelado de datos del capítulo anterior y en la disciplina de pedir solo lo que la interfaz realmente necesita.

Costos asociados a las lecturas

La documentación oficial de precios establece varios principios esenciales:

  • Se cobra por documentos leídos, escritos y eliminados.[cite:2]
  • También se cobra por entradas de índice leídas para satisfacer ciertas consultas.[cite:2]
  • Las consultas con hasta un solo campo de rango no cobran lecturas de entradas de índice, pero algunas combinaciones con dos campos de rango sí lo hacen.[cite:2]
  • Toda consulta tiene un cargo mínimo de una lectura, incluso si no devuelve resultados.[cite:2]
  • Los listeners en tiempo real generan lecturas adicionales conforme cambian los resultados.[cite:2]
  • Los offsets cuestan lecturas por documentos omitidos, por lo que se recomienda usar cursores.[cite:2]

Estas reglas cambian la forma de diseñar búsquedas. Una consulta “bonita” pero demasiado amplia puede ser más cara que una arquitectura con documentos resumidos y filtros más precisos. El costo no es una consecuencia accidental; es una propiedad del diseño.

Ejemplos paso a paso

Ejemplo 1: búsqueda de usuarios por rol y estado

import { collection, query, where, orderBy, getDocs } from 'firebase/firestore';

async function buscarUsuariosActivosPorRol(rol) {
  const usuariosRef = collection(db, 'usuarios');
  const q = query(
    usuariosRef,
    where('rol', '==', rol),
    where('activo', '==', true),
    orderBy('displayName')
  );

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Qué resuelve: un panel administrativo donde se listan docentes o alumnos activos.

Ejemplo 2: filtro por institución

import { collection, query, where, getDocs } from 'firebase/firestore';

async function obtenerAlumnosPorInstitucion(institucionId) {
  const alumnosRef = collection(db, 'alumnos');
  const q = query(alumnosRef, where('institucionId', '==', institucionId));

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Qué resuelve: una vista administrativa centrada en una institución concreta.

Ejemplo 3: consulta por fecha

import { collection, query, where, orderBy, getDocs } from 'firebase/firestore';

async function obtenerTareasVigentes(fechaActual) {
  const tareasRef = collection(db, 'tareas');
  const q = query(
    tareasRef,
    where('fechaEntrega', '>=', fechaActual),
    orderBy('fechaEntrega')
  );

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Qué resuelve: mostrar tareas todavía activas o próximas a vencer. Además, cumple la regla de que el primer ordenamiento usa el mismo campo del rango.[cite:1]

Ejemplo 4: paginación de alumnos

import {
  collection,
  query,
  orderBy,
  limit,
  startAfter,
  getDocs
} from 'firebase/firestore';

async function obtenerPaginaAlumnosSimple(ultimoDocumento = null) {
  const alumnosRef = collection(db, 'alumnos');

  const restricciones = [orderBy('displayName'), limit(30)];
  if (ultimoDocumento) restricciones.push(startAfter(ultimoDocumento));

  const q = query(alumnosRef, ...restricciones);
  const snapshot = await getDocs(q);

  return {
    items: snapshot.docs.map(d => ({ id: d.id, ...d.data() })),
    ultimoDocumento: snapshot.docs[snapshot.docs.length - 1] ?? null
  };
}

Qué resuelve: listas grandes de alumnos en una interfaz de administración sin pagar lecturas por saltos arbitrarios de offset.[cite:2]

Ejemplo 5: búsqueda de tareas activas

import { collection, query, where, orderBy, limit, getDocs } from 'firebase/firestore';

async function obtenerTareasActivasPorCurso(cursoId) {
  const tareasRef = collection(db, 'tareas');
  const q = query(
    tareasRef,
    where('cursoId', '==', cursoId),
    where('estado', 'in', ['publicada', 'en_revision']),
    orderBy('updatedAt', 'desc'),
    limit(20)
  );

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Qué resuelve: mostrar solo las tareas relevantes de un curso y reducir lecturas con un límite claro.

Ejemplo 6: consulta en tiempo real para notificaciones

import { collection, query, where, orderBy, onSnapshot } from 'firebase/firestore';

function escucharNotificacionesPendientes(uid, onData, onError) {
  const notificacionesRef = collection(db, 'usuarios', uid, 'notificaciones');
  const q = query(
    notificacionesRef,
    where('leida', '==', false),
    orderBy('createdAt', 'desc')
  );

  return onSnapshot(
    q,
    snapshot => {
      const items = snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
      onData(items);
    },
    error => onError(error)
  );
}

Qué resuelve: un centro de notificaciones actualizado en tiempo real para el usuario autenticado.

Ejemplo 7: panel administrativo con Collection Group

import { collectionGroup, query, where, orderBy, getDocs } from 'firebase/firestore';

async function obtenerClasesPublicadasPorInstitucion(institucionId) {
  const clasesGroup = collectionGroup(db, 'clases');
  const q = query(
    clasesGroup,
    where('institucionId', '==', institucionId),
    orderBy('fechaProgramada', 'desc')
  );

  const snapshot = await getDocs(q);
  return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}

Qué resuelve: una vista transversal de clases publicadas en todos los cursos de una institución, sin necesidad de recorrer curso por curso.

Comparaciones entre métodos

getDoc() vs getDocs()

Método Cuándo conviene Ventaja principal Riesgo principal
getDoc() Cuando se conoce la ruta exacta del documento Lectura puntual directa No sirve para descubrir conjuntos
getDocs() Cuando se necesita consultar una colección o una búsqueda Flexibilidad para filtros y listas Puede disparar demasiadas lecturas si la consulta es amplia

getDocs() vs onSnapshot()

Método Naturaleza Cuándo usarlo
getDocs() Lectura puntual Búsquedas, cargas iniciales, pantallas no reactivas
onSnapshot() Suscripción en tiempo real Notificaciones, dashboards en vivo, estados cambiantes

limit() vs paginación con cursor

Estrategia Uso ideal Observación
limit() simple Primer subconjunto pequeño Excelente para paneles resumen
limit() + startAfter() Paginación escalable Evita costo de offsets.[cite:2]
offset Evitar en la mayoría de casos Cobra por documentos omitidos.[cite:2]

Buenas prácticas

  • Leer por ID cuando el ID ya es conocido.
  • Diseñar consultas específicas, no genéricas “por si acaso”.
  • Usar limit() en toda vista que no necesite el conjunto completo.[cite:1][cite:2]
  • Paginar con cursores y no con offsets.[cite:2]
  • Mantener consistencia de campos que participan en orderBy().
  • Recordar que orderBy() excluye documentos sin ese campo.[cite:1]
  • Utilizar tiempo real solo donde genere valor real de producto y no solo comodidad técnica.[cite:2]
  • Aprovechar Collection Group cuando la jerarquía existe, pero la vista necesita transversalidad.
  • Pensar siempre en costo por lectura e impacto de índices al diseñar consultas.[cite:2]
  • Documentar consultas críticas del sistema junto con el motivo de su diseño.

Errores comunes

  • Usar getDocs() sobre colecciones enteras cuando se conoce el ID del documento.
  • Olvidar que una desigualdad exige ordenar primero por el mismo campo.[cite:1]
  • No prever que orderBy() solo devuelve documentos donde el campo existe.[cite:1]
  • Usar offsets en paginación grande y pagar lecturas innecesarias.[cite:2]
  • Dejar listeners en tiempo real donde no hacen falta y multiplicar lecturas.[cite:2]
  • Filtrar demasiado del lado del cliente en vez de construir una mejor consulta.
  • Modelar datos sin pensar en cómo se consultarán después.
  • No controlar tamaño ni cantidad del resultado de consultas administrativas.

Resumen

El motor de consultas de Cloud Firestore está profundamente ligado a su modelo documental e indexado. Por eso, construir consultas avanzadas no consiste solo en aprender métodos como where(), orderBy() o limit(), sino en comprender las reglas que hacen posible que la consulta se resuelva de forma eficiente y escalable.[cite:1][cite:2]

Las consultas por igualdad, rango, arrays y conjuntos permiten cubrir una gran variedad de necesidades reales del proyecto del libro: búsqueda de usuarios, filtros por institución, tareas activas, clases jerárquicas, notificaciones en tiempo real y paneles administrativos. Sin embargo, cada consulta tiene restricciones —como el ordenamiento obligatorio en rangos o la exclusión de documentos sin campos ordenados— y también consecuencias económicas medibles, pues Firestore cobra por documentos e índices leídos, con un mínimo de una lectura por consulta.[cite:1][cite:2]

Un desarrollo profesional sobre Firestore exige combinar consultas precisas, buenos límites, paginación con cursores, uso selectivo de tiempo real y un modelo de datos coherente con la forma en que la aplicación recupera información. Esa disciplina permite construir sistemas rápidos, mantenibles y financieramente sostenibles.

Conceptos clave

  • Motor de consultas de Firestore.[cite:1][cite:2]
  • Consulta puntual por documento.
  • query().
  • where().
  • orderBy().[cite:1]
  • limit().[cite:1]
  • limitToLast().[cite:1]
  • Cursores.
  • startAt().
  • startAfter().
  • endAt().
  • endBefore().
  • Paginación con cursores.[cite:2]
  • array-contains.
  • array-contains-any.
  • in.
  • not-in.
  • Collection Group Query.
  • onSnapshot().
  • Costo por documento leído.[cite:2]
  • Costo por entradas de índice leídas.[cite:2]

Preguntas de repaso

  1. ¿Por qué el motor de consultas de Firestore depende tanto de índices?[cite:2]
  2. ¿Cuándo conviene usar getDoc() en lugar de getDocs()?
  3. ¿Qué diferencia existe entre una consulta por igualdad y una consulta por rango?
  4. ¿Por qué una desigualdad exige que el primer orderBy() use el mismo campo?[cite:1]
  5. ¿Qué implica que orderBy() también filtre por existencia del campo?[cite:1]
  6. ¿Qué ventajas tiene paginar con startAfter() frente a usar offsets?[cite:2]
  7. ¿Cuándo usarías onSnapshot() y cuándo preferirías getDocs()?[cite:2]
  8. ¿Qué problema resuelven las Collection Group Queries?
  9. ¿Cómo afectan las entradas de índice leídas al costo de una consulta?[cite:2]
  10. ¿Qué decisiones del modelo de datos influyen más sobre la calidad de las consultas?

Ejercicios prácticos

  1. Implementa una consulta que obtenga todos los docentes activos de una institución específica y los ordene por nombre.
  2. Diseña una consulta que recupere tareas con fechaEntrega futura, ordenadas cronológicamente y limitadas a 15 resultados.[cite:1]
  3. Implementa paginación de alumnos con limit() y startAfter() en páginas de 20 resultados.[cite:2]
  4. Diseña una consulta de Collection Group sobre clases para mostrar las clases publicadas de toda una institución.
  5. Implementa un listener con onSnapshot() para mostrar notificaciones no leídas del usuario actual y analiza cuándo dejaría de ser rentable mantenerlo activo.[cite:2]
  6. Reescribe una consulta amplia de panel administrativo usando filtros y límites más precisos, justificando cómo se reducen lecturas.
  7. Analiza un caso donde orderBy() excluya documentos inesperadamente porque el campo no existe y propone una solución.[cite:1]
  8. Diseña una consulta por array-contains para cursos asociados a un grupo y explica cuándo esa solución dejaría de ser adecuada.

Bibliografía y referencias oficiales