We deleted every refresh timer in the product

· ~9 minute read

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.

Writers SFTP / FTPS uploads Portal & WebDAV admin mutations retention worker audit events folder monitor Notification hub topics, coalesced sessions · users · audit folders · kb · throughput publish Browser / desktop admin signal: "sessions changed" no data in the frame Authorized REST re-fetch own credentials, own view same checks as always push
Signal, then re-fetch: the socket says that something changed; the REST API decides what you get to see.

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

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.