DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our page polls a 16-character fingerprint, and only a change is allowed to re-render it

A campaign in Nakodo is meant to be left alone. It searches, it writes to the contacts it finds, it reads the replies. So the screen you leave open is a screen whose numbers are wrong a minute later, and the honest fix is that the page should fill itself in.

router.refresh() on a timer does that. It keeps client state, so filters you set and text you are typing survive. What it does not keep is cheap: the campaign page is several counts, a list of creators and a couple of correlated subqueries, and running all of that every thirty seconds to find out that nothing happened is the whole cost of the feature with none of the benefit.

So the page asks a smaller question first.

One question, 16 characters

src/app/api/campaigns/[id]/changes/route.ts is the whole endpoint:

export async function GET(_request: Request, ctx: RouteContext<"/api/campaigns/[id]/changes">) {
  const { id } = await ctx.params;
  const user = await getUser();
  if (!user) return Response.json({ error: "Signed out" }, { status: 401 });
  const changes = isUuid(id) ? await campaignChanges(id, user.id) : null;
  if (changes === null) return Response.json({ error: "Not found" }, { status: 404 });
  return Response.json(
    { v: createHash("sha1").update(changes).digest("base64url").slice(0, 16) },
    { headers: { "Cache-Control": "private, no-store" } },
  );
}
Enter fullscreen mode Exit fullscreen mode

You can call it yourself:

$ curl -i https://nakodo.app/api/campaigns/11111111-1111-1111-1111-111111111111/changes
HTTP/2 401
content-type: application/json
cache-control: public, max-age=0, must-revalidate

{"error":"Signed out"}
Enter fullscreen mode Exit fullscreen mode

The hash is not a security boundary, so sha1 truncated to 16 base64url characters is fine. It is a cache key for "the page you are looking at", and the only thing it has to do is differ when the page would differ.

Which means the string it hashes is the interesting part. campaignChanges reads the campaign row for the fields the pages render out of it (status, both pause columns, the hold on leads, updatedAt), then three correlated subqueries that each come back as a two or three element JSON array:

creators: sql`(select json_build_array(count(*), max(cc.updated_at)) from ${campaignChannels} cc where cc.campaign_id = ${campaignId})`,
threads: sql`(select json_build_array(count(*), max(t.updated_at)) from ${outreachThreads} t where t.campaign_id = ${campaignId})`,
keywords: sql`(select json_build_array(count(*), count(k.last_searched_at), max(k.last_searched_at)) from ${keywords} k where k.campaign_id = ${campaignId} and k.enabled)`,
Enter fullscreen mode Exit fullscreen mode

A count alone is not enough: a creator can be rejected or a thread can move from one status to another without the count moving, and max(updated_at) catches that. max(updated_at) alone is not enough either, because a deleted row lowers nothing. Count plus high water mark catches both, in one round trip, and the open jobs count rides along in a Promise.all beside it.

The campaign's own updatedAt is in there and does none of the work. Nothing in the pipeline touches the campaign row when it files a creator.

The first answer is never a change

The client is src/app/(app)/campaigns/[id]/auto-refresh.tsx, a component that renders null. The subtle line in it is this one:

const before = last ?? v;
last = v;
if (before === v || rendered.get(campaignId) === v) return false;
Enter fullscreen mode Exit fullscreen mode

The server rendered the page from some state, and the first fingerprint we ever see describes that state. If the first answer counted as a change, every mount would immediately refresh the page it had just finished rendering. So the first answer is its own baseline and only the second one can differ.

rendered is the other half of that, and it is a module-level Map:

const rendered = new Map<string, string>();
Enter fullscreen mode Exit fullscreen mode

Two of these can be mounted at once. The campaign header has one, and the outreach page has one, because either can be the page you are sitting on. Both poll, both see the same change, and only one refresh is wanted: whoever gets there first writes the fingerprint into the map, and the other one reads it back and declines. A useRef cannot do this. The two components do not share a tree branch worth lifting state into, and the map outliving both of them is exactly the point, because a navigation between the two should not re-refresh for a change that is already on screen.

Quiet checks get cheaper

quiet = fresh || starting ? 0 : quiet + 1;
timer = setTimeout(..., every * 2 ** Math.min(quiet, 2));
Enter fullscreen mode Exit fullscreen mode

Thirty seconds, then a minute, then two minutes, and it stays there. Any change at all drops it back to thirty. A campaign that is working is noisy and a campaign that is finished is silent, so the poll sorts itself out without anyone telling it which it is.

The starting term is there because the first check is the baseline and a baseline is not a quiet answer. Counting it as quiet would have the first two checks after a mount already half way up the backoff.

Then the tab goes away:

timer = setTimeout(() => {
  timer = null;
  if (document.visibilityState === "visible") void check();
}, every * 2 ** Math.min(quiet, 2));

const onVisible = () => {
  if (document.visibilityState === "visible" && timer === null) void check();
};
Enter fullscreen mode Exit fullscreen mode

A hidden tab is not polled. The timer still fires, finds the tab hidden, and leaves timer null without rescheduling, which is the state the visibilitychange handler looks for: if there is no timer pending, a check fell due while you were away, so run it now. If there is one, you came back inside the window and it is still coming. No separate "was hidden" flag, no double checks when you flick between tabs.

The two remaining flags are duller and both earn their place. busy stops an overlapping run, since check is async and a slow answer must not let the next timer in. stopped is set when the endpoint says 401 or 404, because a signed out tab or a deleted campaign has nothing to poll for, and the next page they open will tell them so properly. await changed().catch(() => false) handles a dropped connection by treating it as a quiet answer, so a laptop that slept wakes up and carries on instead of throwing in an effect.

One request, not two

The route is left out of the proxy matcher, which is unusual in an app where every signed in path goes through it:

api/campaigns/[^/]+/changes$
Enter fullscreen mode Exit fullscreen mode

Everything else behind the session gets its cookie refreshed by src/proxy.ts before the route runs. Letting this one through the matcher as well would mean each poll pays for a session read twice: once in the proxy, once in getUser(). So it is excluded and checks the session itself, and because it is a route handler it can still set a refreshed cookie if Supabase hands it one. The 401 above is that check talking.

That is the whole feature. One endpoint that answers in 16 characters, one component that renders nothing, a map at module scope, and a page that gets re-rendered when it is wrong and left alone when it is right. The product promise is on how it works, and what a plan lets a campaign do in a day is on pricing; the point of the poll is that neither of those requires you to sit there pressing reload.

Top comments (0)