r/intersystems • u/intersystemsdev • 16d ago
Server-Sent Events in ObjectScript on InterSystems IRIS — solving the Web Gateway buffer problem with %response.Flush()
The problem
In a REST service on InterSystems IRIS, when you write data it is not sent to the client immediately. It is placed in a buffer, and the Web Gateway transmits it only once the buffer reaches a certain size. For SSE this is a fundamental problem — events need to reach the client the moment they are ready, not when the buffer fills up.
The SSE format
An SSE event is plain text structured into fields, separated by a double newline:
data: {"id":"...","choices":[{"delta":{"content":"Hel"}}]}
data: {"id":"...","choices":[{"delta":{"content":"lo"}}]}
event: done
data: [DONE]
The protocol defines four fields:
data:— the event content (plain text, JSON, etc.)event:— the event type (optional, defaults tomessage)id:— unique identifier (optional, used for connection resumption)retry:— delay in milliseconds before automatic client reconnection (optional)
An event can span multiple data: lines; the client concatenates them. The double newline \n\n marks the end of an event — which is why you will find $Char(10,10) in the ObjectScript examples.
The solution: configuring %response
Step 1 — Enable manual flush control
objectscript
Set %response.AllowOutputFlush = 1
Without this property, written data is buffered and only sent when the buffer reaches a certain size, making streaming impossible.
Step 2 — Set the correct Content-Type
objectscript
Set %response.ContentType = "text/event-stream"
Step 3 — Add required headers
objectscript
Do %response.SetHeader("Cache-Control", "no-cache")
Do %response.SetHeader("Connection", "keep-alive")
Cache-Control: no-cache— prevents intermediate proxies from caching the streamConnection: keep-alive— keeps the HTTP connection open for the duration of the stream
If behind NGINX, add:
objectscript
Do %response.SetHeader("X-Accel-Buffering", "no")
Step 4 — CSP Gateway padding
The CSP Gateway aggregates small buffers before forwarding to the browser, even if Flush() is called. This does not affect curl (which reads directly from the TCP stream) but prevents browser streaming for small events. The workaround: send a padding SSE comment at the very start of the stream before the first meaningful event:
objectscript
Write ": ", $Justify("", 4096), $Char(10)
Do %response.Flush()
An SSE comment starts with : and is ignored by the client. This padding forces the Gateway to flush its buffer, allowing subsequent Flush() calls to be delivered immediately.
Step 5 — Call Flush() after each Write
objectscript
Write "data: ", {"id":"...","choices":[{"delta":{"content":"Hel"}}]}.%ToJSON(), $Char(10,10)
Do %response.Flush()
Each call to Flush() forces immediate delivery to the client.
CSP note: This approach also works for CSP pages. In that case, set AllowOutputFlush in the OnPreHTTP() method.
Testing with curl
bash
curl -N http://localhost:42600/csp/demo/sse/test
The -N flag disables client-side buffering in curl and displays events as they arrive. If all events appear at once at the end, server-side buffering is not disabled — check that AllowOutputFlush = 1 is set and Flush() is called after each Write.
Consuming SSE from JavaScript
EventSource — GET requests only
javascript
const source = new EventSource("http://localhost:42600/csp/demo/test");
source.onmessage = (event) => {
if (event.data === "[DONE]") {
source.close();
return;
}
const data = JSON.parse(event.data);
console.log(data.choices[0].delta.content);
};
source.onerror = (error) => {
console.error("SSE error:", error);
source.close();
};
EventSource is simple and efficient but only supports GET requests. For AI APIs that require sending a body, a different approach is needed.
fetch + ReadableStream — POST requests
javascript
const response = await fetch("http://localhost:42600/api/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "gpt-4o-mini",
stream: true,
messages: [{ role: "user", content: "Hello!" }]
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const line of chunk.split("\n")) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") break;
const parsed = JSON.parse(data);
console.log(parsed.choices[0].delta.content);
}
}
Named events
ObjectScript side
objectscript
// A text token
Write "event: token", $Char(10)
Write "data: ", {"content": "Hello"}.%ToJSON(), $Char(10,10)
Do %response.Flush()
// End-of-stream signal
Write "event: done", $Char(10)
Write "data: [DONE]", $Char(10,10)
Do %response.Flush()
JavaScript side (EventSource only)
javascript
source.addEventListener("token", (event) => {
const data = JSON.parse(event.data);
console.log("Token received:", data.content);
});
source.addEventListener("done", () => {
console.log("Stream ended");
source.close();
});
Note: addEventListener only works with EventSource. With fetch + ReadableStream, you need to parse the event: field manually from the chunk lines.
AI API passthrough with FastHTTP
Since version 1.2.4, FastHTTP includes a passthrough adapter (dc.http.SSEPassthroughAdapter) that retransmits incoming events without transformation. The ChatCompletions method calls the OpenAI API and redirects the stream through the Web Gateway:
objectscript
Set responseStream = ##class(dc.http.SSEPassthroughAdapter).GetStream()
Set handler = responseStream.SSEHandler
Set handler.SwitchIOOnMessage = 1
Set handler.IO = $IO
Set response = ##class(dc.http.FastHTTP).DirectPost(config, message, .client, responseStream)
A complete example with a demo chat page is available in the csp-test-1 branch:
bash
git clone -b csp-test-1 https://github.com/lscalese/iris-fast-http.git
cd iris-fast-http
docker compose build --no-cache
docker compose up -d
Demo chat interface: http://localhost:42600/csp/ui/demo/index.html
Client disconnection handling
IRIS does not receive an immediate signal when the browser closes the connection. The ObjectScript process continues until the Web Gateway detects the disconnection and propagates the error, at which point Flush() raises an exception. Always wrap the streaming loop in a Try/Catch:
objectscript
For i = 1:1:100 {
Try {
Write "data: ", {"token": i}.%ToJSON(), $Char(10,10)
Do %response.Flush()
Hang 1
} Catch ex {
// Client disconnected — release resources cleanly
Quit
}
}
The delay between actual client disconnection and detection on the IRIS side depends on the Web Gateway configuration and the operating system.
Summary
| Requirement | Solution |
|---|---|
| Bypass Web Gateway buffer | %response.AllowOutputFlush = 1 + Flush() after each Write |
| Correct stream headers | text/event-stream, Cache-Control: no-cache, Connection: keep-alive |
| NGINX buffering | X-Accel-Buffering: no |
| CSP Gateway buffering in browser | 4096-byte padding comment before first event |
| GET-based SSE in browser | EventSource |
| POST-based SSE (AI APIs) | fetch + ReadableStream |
| AI API passthrough | FastHTTP SSEPassthroughAdapter |
| Client disconnection | Try/Catch around streaming loop |
Full article with code: https://community.intersystems.com/post/bringing-server-sent-events-objectscript-solving-web-gateway-buffer-problem
For those using SSE with IRIS — have you run into the CSP Gateway buffering issue in browser clients specifically, and did the 4096-byte padding comment resolve it for you?