One of our favorite Mongoose Studio features is also one that most people will never notice: query results stream to the frontend using Server-Sent Events (SSE). Most database GUIs use pagination to display results: load a fixed number of documents at a time, display them when all the documents are available. For smaller datasets and fast queries, pagination and SSE are hard to distinguish. But when you have a slow query that returns a few documents but not enough to fill the full page, SSE can mean the difference between getting the answer immediately or waiting for minutes.
For example, the following video shows Mongoose Studio streaming the results of a query for $where: sleep(1000)||true: a query that sleeps for 1 second between returning each document.
With pagination, you would need to wait at least 20 seconds to view even a single result.
I implemented this because sometimes I end up running a slow query in the GUI and I want to start looking at the first couple of results while the query is still running to decide whether I want to continue running the query. Potentially waiting for minutes for a full page to load or adding a limit only to have to rerun the query later was frustrating.
How Server-Sent Events (SSE) work
Server-Sent Events are a way to stream data from the server to the client over HTTP. SSE is like a standard HTTP request, but the server sends data to the client as it becomes available, rather than waiting for the entire response to be ready. SSE is reasonably easy to implement with Express, just requires some extra boilerplate. For exmaple, the below Express code sends back a hardcoded list of words over SSE, one word at a time, with a 1 second delay between each word.
const express = require('express');
const app = express();
const words = ['Lorem', 'ipsum', 'dolor', 'sit', 'amet'];
app.get('/sse', (req, res) => {
// Set the headers that tell the client this is an SSE stream.
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
res.flushHeaders();
// Send back the first 5 words of lorem ipsum, one at a time,
// with a 1 second delay between each word.
let i = 0;
const interval = setInterval(() => {
if (i >= words.length) {
clearInterval(interval);
res.end();
return;
}
// Each SSE message is a line prefixed with `data: ` and terminated
// by a double newline.
res.write(`data: ${words[i]}\n\n`);
++i;
}, 1000);
// Clean up the interval if the client disconnects early.
req.on('close', () => clearInterval(interval));
});
const server = app.listen(3000, () => {
console.log('Listening on port 3000');
});
If this feels similar to ChatGPT or Claude's streaming responses, "you're absolutely right!" LLM chatbots frequently use SSE to stream responses back to the client. WebSockets are another option for streaming, but SSE often plays nicer with load balancers, reverse proxies, and other HTTP infrastructure due to the fact that SSE is just a plain old HTTP request under the hood.
Run the above server and run curl https://localhost:3000/sse, you'll get back the following result.
$ curl http://localhost:3000/sse
data: Lorem
data: ipsum
data: dolor
data: sit
data: amet
The data lines come in one at a time, with a 1 second delay between each word.
Plugging in MongoDB Cursors
Cursors are how you can load documents from MongoDB one at a time.
For example, here's how you can use a cursor to stream words from a Word collection using an async iterator.
const wordsCursor = await Word.find().cursor();
for await (const word of wordsCursor) {
console.log(word.text);
}
Under the hood, MongoDB cursors use batching, which means the cursor will load 1000 documents by default under the hood, but give you the documents one at a time.
For example, consider the following code, which uses a cursor to load words from the words collection with the sleep(1000) condition to simulate a slow query.
If you make a request to /sse, you'll see the words appear in one batch after a 5 second delay, not one at a time with a 1 second delay between each.
const express = require('express');
const mongoose = require('mongoose');
const words = ['Lorem', 'ipsum', 'dolor', 'sit', 'amet'];
const Word = mongoose.model('Word', new mongoose.Schema({
order: { type: Number, required: true, unique: true },
text: { type: String, required: true }
}));
const app = express();
app.get('/sse', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
res.flushHeaders();
const cursor = Word.find({ $where: 'sleep(1000) || true' }).sort({ order: 1 }).cursor();
req.on('close', () => cursor.close());
cursor.on('data', doc => {
res.write(`data: ${doc.text}\n\n`);
});
cursor.on('end', () => res.end());
cursor.on('error', err => {
console.error(err);
res.end();
});
});
async function run() {
await mongoose.connect('mongodb://127.0.0.1:27017/sse_test');
// Upsert the 5 words into the Word model on startup.
await Promise.all(words.map((text, order) => Word.updateOne(
{ order },
{ $set: { text } },
{ upsert: true }
)));
app.listen(3000, () => console.log('Listening on port 3000'));
}
run().catch(err => {
console.error(err);
process.exit(1);
});
That's cursor batching in action: the cursor waits for a full batch before returning any results.
However, batchSize is configurable.
Mongoose Studio uses batchSize(1) under the hood because that makes more sense for our use case of surfacing the documents to the client as soon as they're ready without batching.
This increases the number of requests made to the MongoDB server, but I think the additional overhead is worth the improved responsiveness.
Add batchSize(1) to the above code and you'll see the words appear one at a time with a 1 second delay between each.
const cursor = Word.find({ $where: 'sleep(1000) || true' }).batchSize(1).sort({ order: 1 }).cursor();
Handling the Response on the Frontend
Thankfully fetch() has built-in support for SSE, so no need to add any external packages to consume SSEs on the frontend.
The following HTML shows an example of consuming an SSE stream using fetch() - there's a bunch of boilerplate with getReader() and TextDecoderStream() that you need to set up to read the stream and some logic to parse out the individual messages.
The reader emits text chunks that you need to buffer and split into individual messages.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SSE Fetch Test</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 40px auto; }
#output { border: 1px solid #ccc; padding: 12px; min-height: 120px; font-family: monospace; white-space: pre-wrap; }
button { padding: 8px 16px; font-size: 16px; }
</style>
</head>
<body>
<h1>SSE Fetch Test</h1>
<button id="start">Start streaming</button>
<div id="output"></div>
<script>
const output = document.getElementById('output');
function log(line) {
output.textContent += line + '\n';
}
async function run() {
output.textContent = '';
const res = await fetch('http://localhost:3000/sse');
// The response body is a ReadableStream. Rather than awaiting the entire
// response with `res.text()`, we read it chunk by chunk as the server
// sends each Server-Sent Event.
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
// SSE messages are separated by a blank line (`\n\n`). Buffer partial
// chunks and pull out complete messages as they arrive.
buffer += value;
let index;
while ((index = buffer.indexOf('\n\n')) !== -1) {
const message = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
// Each SSE message line is prefixed with `data: `.
const data = message.replace(/^data: /, '');
log(new Date().toISOString() + ' received: ' + data);
}
}
log('Stream closed');
}
document.getElementById('start').addEventListener('click', () => {
run().catch(err => log('Error: ' + err.message));
});
</script>
</body>
</html>
Here's what it looks like in the browser:


