Until this release, every console in Stylus SFTP Server kept itself fresh the way most admin tools do: on a timer. The Dashboard re-fetched every 3 seconds. Activities every 10. Sessions every 30. The Users grid every 10. The desktop admin ran a 5-second poll behind every tab. The File Portal checked for new files every 30 seconds. Content Retention had its own 30-second loop.
Seven timers, each a small institutionalised lie. A session that ends the moment you look at the Sessions tab stays on screen for up to half a minute. A folder created in the desktop admin doesn't exist in the web admin until the next tick. And every one of those timers runs at full cadence when nothing whatsoever is happening — an idle server with four open admin tabs was answering a steady drizzle of identical queries all night, to report that nothing had changed.
As of build 707, all seven timers are gone. Not lengthened, not made adaptive — deleted. Every surface in the product — web admin, desktop admin, File Portal, Knowledge Base — updates because the server told it something changed. This post is about the design that got us there, and about a caching bug along the way that made a 21-millisecond system look like a 30-second one.
Push the signal, not the data
The tempting version of "realtime" is to push the actual data over the socket: a session ends, so send the updated session list to everyone watching. We didn't build that, and the reason is authorization.
Every read path in the server already answers one hard question: is this caller allowed to see this? The REST API checks the signed-in operator's rights. The Portal checks folder grants. The Knowledge Base checks topic visibility per group. Pushing data through a socket creates a second delivery path that has to re-answer all of those questions, perfectly, forever, in parallel with the first — and any place the two disagree is a data leak.
So the hub pushes something deliberately worthless to an eavesdropper: a signal. A tiny frame that says "the thing you subscribed to has changed — version 41." No names, no paths, no rows. The client then re-fetches through the same authorized REST endpoint it has always used, with its own credentials, getting exactly the view it is entitled to. One authorization model, one data path, and the socket carries nothing worth stealing.
The failure mode is equally benign. Lose a signal — a dropped connection, a restarting browser — and the worst case is a console that is briefly as stale as the polling version used to be always. Reconnect, re-fetch, current again. Nothing can be corrupted by a missed message, because messages carry no state.
A storm must cost one signal
An SFTP server's load is bursty by nature. A partner drops 300 files in a directory; a batch job deletes a night's worth of staging; an audit-heavy hour writes thousands of rows. If each event became a frame to every subscriber, the hub would amplify every burst by the number of open consoles.
So nothing in the pipeline queues per event. Each topic is a single row holding a version counter: publishing bumps the counter, and a drain loop sends subscribers one signal per topic per sweep, however many bumps landed in between. The queue physically cannot grow — a thousand changes to one topic occupy exactly the same space as one change. On top of that, the filesystem monitor throttles per folder, so a 300-file Explorer drop collapses to a handful of signals rather than 300. The client was going to re-fetch the listing anyway; it does not matter whether one signal or three hundred triggered it.
Watching the door nobody logs through
Here is the case polling actually handled by accident, and push has to handle on purpose: files that appear without going through the server. Someone copies a file into a shared folder with Windows Explorer. A downstream job writes its results directly into a virtual folder's backing directory. No upload event fired, because no upload happened — the bytes arrived through the filesystem.
The old Portal found those files because it re-listed everything every 30 seconds. A push system that only knows about the server's own writes would simply never see them. So the server now watches the backing directories of shared virtual folders natively and publishes to the same topics its own writers use. To a Portal user, a file dropped in Explorer and a file uploaded over SFTP now behave identically: both appear on screen as they land.
One socket per browser, tickets at the door
Two plumbing decisions are worth recording.
Authentication. A WebSocket handshake is awkward territory for session cookies and impossible territory for custom headers. Instead, a page that wants live updates first calls a ticket endpoint over its ordinary authenticated session, gets back a single-use ticket, and presents it once during the socket handshake. The ticket is consumed on use — replaying it buys nothing. And subscriptions are checked against the caller's actual rights when they are made: a Knowledge reader can subscribe to the knowledge topics their groups can see, and structurally cannot subscribe to admin topics at all — the two surfaces have separate authorizers, composed so that crossing them is not a permission failure but an impossibility.
Connection count. An administrator with the Dashboard, Sessions, and Audit open in three tabs should not hold three sockets. The browser client lives in a shared worker: one socket per browser, carrying the union of every tab's subscriptions, with per-tab fallback where shared workers aren't available. The desktop admin speaks the same protocol with a reconnect backoff and a liveness watchdog — and its "server alive" indicator now simply reads the state of the socket, which is more honest than any poll: if the heartbeat stops, the server is gone.
The bug: 21 milliseconds, reported as 30 seconds
Shipping this produced the kind of bug report we want to frame. On one machine, session updates appeared instantly. On another — same build, same server — they took up to 30 seconds, and the delay varied per visit. Stochastic latency in a push system is an alarming symptom; we measured the server end and found it delivering signals in 21 milliseconds, end to end, every time.
The cause was nowhere near the hub. The browser had cached the admin application's shell from a build that predated the notify client. The stale page ran happily against the new server — minus the code that subscribes to the socket — so it silently rode the 30-second safety-net refresh we had left in during the transition. The fix was to serve the handful of app-shell files with no-store so an upgrade always delivers the current shell. The lesson generalises: when you change how a page learns about the world, the page itself becomes a cache-invalidation problem.
That safety net is gone now, along with all the others.
What this means operationally
- It's on by default. The hub starts with the server on its own port (9985 by default) and is configured in a
<notification-hub>block insftp-server.xml. - It cannot take the server down. Hub startup is failure-isolated: if the port is taken or the hub cannot start, file transfer starts normally and the consoles degrade to manual refresh. A monitoring convenience must never cost you the actual product.
- Co-hosted instances need distinct hub ports — two servers on one machine will otherwise contend for 9985.
- Idle means idle. An open console on a quiet server generates heartbeats and nothing else. The background query drizzle is gone.
The charts moved to the same wiring: transfer throughput publishes as it happens, and the activity charts draw live — including a final push on the quiet edge so a finished transfer's rate drops to zero on screen instead of flat-lining at its last sample.
A monitoring console is a claim about the present. For years, ours was really a claim about some moment in the last thirty seconds, and everyone had quietly agreed not to mind. It turns out you stop minding a lot more once it's simply true.