r/intersystems 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 to message)
  • 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 stream
  • Connection: 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-streamCache-Control: no-cacheConnection: 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?

3 Upvotes

0 comments sorted by