Documentation
Subscriptions (SSE)

Subscriptions (SSE)

Stream live create, update, and delete events to browser clients over Server-Sent Events β€” no WebSocket setup required.

SSE works over a plain HTTP/1.1 GET request. Browsers have built-in EventSource support, making it the simplest possible real-time transport for dashboards and admin panels.

How It Works

Opening GET /api/:model/subscribe establishes a long-lived HTTP stream. omni-rest polls Prisma on a configurable interval and fans out mutation events to every connected client.

Client A  ──────┐
Client B  ───────  One shared poll per model
Client C  β”€β”€β”€β”€β”€β”€β”˜  (zero DB overhead when no one is watching)

Key performance properties:

PropertyDetail
Lazy startNo DB queries run for a model until the first client subscribes
Fan-out100 clients on department β†’ 1 shared setInterval, not 100
Instant teardownInterval cleared the moment the last client disconnects

Quick Start

1. Enable on the server

No extra config required β€” the endpoint is automatically registered alongside the CRUD routes:

import { expressAdapter } from "omni-rest/express";
 
app.use("/api", expressAdapter(prisma, {
  allow: ["department", "order"], // only these models get /subscribe
}));

That's it. GET /api/department/subscribe is now live.

2. Connect from the browser

const source = new EventSource("/api/department/subscribe");
 
source.onmessage = (e) => {
  const { event, model, record, id } = JSON.parse(e.data);
 
  if (event === "create") console.log("New record:", record);
  if (event === "update") console.log("Updated:", record);
  if (event === "delete") console.log("Deleted id:", id);
};
 
source.onerror = () => source.close(); // reconnect handled by browser

Event Shape

Every message has the following structure:

interface SseEvent {
  event: "create" | "update" | "delete";
  /** Original Prisma model name, e.g. "Department" */
  model: string;
  /** Full record for create / update events */
  record?: Record<string, any>;
  /** Primary-key value for delete events */
  id?: unknown;
}

Example stream:

data: {"event":"create","model":"Department","record":{"id":5,"name":"Engineering"}}

data: {"event":"update","model":"Department","record":{"id":5,"name":"Engineering Dept"}}

data: {"event":"delete","model":"Department","id":5}

: heartbeat

: heartbeat

Lines starting with : are SSE comment lines (heartbeats). They keep the TCP connection alive through proxies and load balancers but are invisible to EventSource.onmessage.

Change Detection

omni-rest uses a polling watermark strategy that works with every Prisma version and every database β€” no Prisma extensions or DB triggers required.

Model fields availableCreatesUpdatesDeletes
createdAt + updatedAtcreatedAt > watermarkupdatedAt > watermarkID-set diff
updatedAt onlyID-set diffupdatedAt > watermarkID-set diff
No timestampsID-set diffβ€”ID-set diff
⚠️

Tip: Add createdAt DateTime @default(now()) and updatedAt DateTime @updatedAt to your Prisma models for the most accurate and efficient event detection. Without timestamps, only create and delete events can be detected (not field-level updates).

Configuration

Tune polling and heartbeat intervals globally via subscription in your adapter options:

expressAdapter(prisma, {
  subscription: {
    pollInterval: 1000,        // How often to query Prisma, ms. Default: 1000
    heartbeatInterval: 30_000, // How often to send keepalive. Default: 30000
  },
});
OptionTypeDefaultDescription
pollIntervalnumber1000Prisma poll frequency in ms. Lower = faster events, higher DB load.
heartbeatIntervalnumber30000SSE keepalive comment frequency in ms.

Cost tip: Set pollInterval higher (e.g. 5000) for tables that change infrequently. The interval only runs when at least one client is connected, so inactive tables are completely free.

Guards

The existing GET guard for a model is applied before the SSE stream opens. A blocked request receives a 403 JSON response β€” the stream never starts.

expressAdapter(prisma, {
  guards: {
    order: {
      // Same guard used for GET /api/order also gates GET /api/order/subscribe
      GET: async ({ id, body }) => {
        if (!req.user?.isAdmin) return "Admin access required";
        return null;
      },
    },
  },
});

Field Guards

fieldGuards apply to SSE events just like regular GET responses β€” hidden and writeOnly fields are stripped from every record before it is streamed:

expressAdapter(prisma, {
  fieldGuards: {
    user: {
      hidden:    ["passwordHash", "salt"],
      writeOnly: ["resetToken"],
    },
  },
});

Records in create and update events will never contain passwordHash, salt, or resetToken.

Adapter Support

⚠️

Next.js is not supported. Next.js Route Handlers run in a serverless function model that cannot hold long-lived HTTP connections. Use the WebSocket approach or a dedicated event service for Next.js real-time needs.

Works out of the box. Uses res.write() on the underlying Node.js ServerResponse.

import { expressAdapter } from "omni-rest/express";
 
app.use("/api", expressAdapter(prisma, {
  subscription: { pollInterval: 2000 },
}));
 
// GET /api/department/subscribe β†’ SSE stream

Multiple Concurrent Subscribers

The SubscriptionBus handles this transparently. A single setInterval is shared across all clients watching the same model:

GET /api/department/subscribe  ← client 1 connects β†’ bus created, poll starts
GET /api/department/subscribe  ← client 2 connects β†’ joins existing bus
GET /api/department/subscribe  ← client 3 connects β†’ joins existing bus

// All three clients share ONE poll interval
// DB is queried once per tick regardless of subscriber count

client 1 disconnects β†’ removed from bus (bus stays alive, 2 listeners left)
client 2 disconnects β†’ removed from bus (bus stays alive, 1 listener left)
client 3 disconnects β†’ removed from bus (0 listeners β†’ bus torn down, poll stops)

Using the SubscriptionBus Directly

For custom routes or non-standard integrations, you can access the SubscriptionBus from the router instance:

import { createRouter, formatSseEvent, formatSseHeartbeat } from "omni-rest";
 
const { handle, modelMap, subscriptionBus } = createRouter(prisma, options);
 
// Custom SSE endpoint
app.get("/live/:model", async (req, res) => {
  const meta = modelMap[req.params.model];
  if (!meta) return res.status(404).json({ error: "Not found" });
 
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.flushHeaders();
 
  const unsubscribe = await subscriptionBus.subscribe(meta, (event) => {
    res.write(formatSseEvent(event));
  });
 
  const hb = setInterval(() => res.write(formatSseHeartbeat()), 30_000);
 
  req.on("close", () => {
    clearInterval(hb);
    unsubscribe();
  });
});

TypeScript Types

import type { SseEvent, SubscriptionOptions } from "omni-rest";
 
// Event shape streamed to clients
type SseEvent = {
  event: "create" | "update" | "delete";
  model: string;
  record?: any;   // present for create / update
  id?: unknown;   // present for delete
};
 
// Passed via PrismaRestOptions.subscription
type SubscriptionOptions = {
  pollInterval?: number;      // default: 1000 ms
  heartbeatInterval?: number; // default: 30000 ms
};

Best Practices

Restrict the allow list

Only expose models that genuinely need real-time updates. Every allow-listed model becomes a potential SSE endpoint.

Tune poll intervals per environment

Use a shorter interval in development (500ms) and a longer one in production (2000–5000ms) to balance freshness against DB load.

Always add a GET guard for sensitive models

The SSE endpoint reuses the GET guard. Any model without a guard is publicly subscribable.

Use createdAt + updatedAt on Prisma models

These two fields unlock the full watermark-based event detection. Without them, updates to existing records cannot be detected.

Monitor active subscribers

The SubscriptionBus exposes activeSubscriberCount and activeModels for diagnostics β€” wire these into your metrics endpoint.

app.get("/metrics/sse", (req, res) => {
  res.json({
    subscribers: subscriptionBus.activeSubscriberCount,
    models: subscriptionBus.activeModels,
  });
});