Architecture

How Twitter and LinkedIn Refresh Your Feed Without You Noticing

How social feeds check for new posts in the background, buffer them off-screen, and show a "New posts" pill, with working code for polling and SSE.

Sanju Singh
•
6 min read
How Twitter and LinkedIn Refresh Your Feed Without You Noticing

TL;DR: Your feed isn't refreshing. The client quietly checks for new items in the background, holds them in memory, and shows a small "New posts" pill. Nothing changes on screen until you ask for it. This post walks through the pattern and a minimal implementation, including the server side.

A note on sources: I'm describing patterns visible in public engineering posts and in the browser's Network tab. I don't have access to Twitter/X's or LinkedIn's private code, and both change their internals often. Where I'm unsure, I say so. Verify against DevTools before relying on any specific detail.

Why it feels silent

Silent feed updates are mostly a UX decision, not a networking trick. If new posts were injected at the top while you were reading, the content would jump under your cursor. The usual fix has four parts:

  1. Check for updates in the background.

  2. Keep the results in a buffer, not in the DOM.

  3. Tell the user something is waiting (the pill).

  4. Apply the update only on a user action, such as a click or scrolling to the top.

Step 1: Choose a transport

There are four common ways to get updates from a server to a browser.

Transport

How it works

Good for

Watch out for

Short polling

Client calls an endpoint on a timer

Feeds, anything tolerant of a delay

Wasted requests; needs jitter and backoff

Long polling

Server holds the request open until there's data

Near-real-time without special infrastructure

Reconnect churn; many open requests

Server-Sent Events (SSE)

One long-lived HTTP response, server pushes events

One-way push: feeds, notifications, reactions

Connection limits on HTTP/1.1; proxy buffering

WebSockets

Full-duplex persistent connection

Chat, collaboration, anything two-way

More infrastructure; harder to scale and debug

A feed is mostly one-directional (server to client), which is why short polling and SSE fit it well. LinkedIn's engineering blog has described a real-time platform built on SSE for things like reactions and messaging. Twitter has historically had a streaming "live pipeline" for real-time signals. I'm not certain how much of either site's current home feed uses push versus polling, so check the Network tab yourself.

Step 2: Ask for the delta, not the feed

The client sends the newest item it has seen, as a cursor or ID, and the server returns only what's newer, or even just a count:

GET /api/feed/updates?since=1843920011

{ "count": 12, "items": [ ... ] }

A count-only variant is cheaper still. The client renders the pill and fetches the full items only when the user clicks.

Here is a simple server endpoint (Express and Postgres style):

app.get("/api/feed/updates", async (req, res) => {
  const since = req.query.since;
  const followedIds = await getFollowedIds(req.user.id); // your own lookup

  const { rows: items } = await db.query(
    `SELECT id, author_id, body, created_at
       FROM posts
      WHERE id > $1 AND author_id = ANY($2)
      ORDER BY id DESC
      LIMIT 50`,
    [since, followedIds]
  );

  res.set("Cache-Control", "no-store");
  res.json({ count: items.length, items });
});

This only works if IDs increase over time (Snowflake IDs, ULIDs, or an auto-increment column). If your feed is ranked by an algorithm instead of by time, a plain id > since check isn't enough. Use a server-issued cursor token that encodes position in the ranked list.

Step 3: Buffer, don't render

Here is a minimal polling client with jitter, backoff, and tab-visibility handling:

const BASE_MS = 45_000;
let newestId = feed[0]?.id;
let buffer = [];
let failures = 0;
let timer;

const jitter = (ms, pct = 0.2) => ms * (1 + (Math.random() * 2 - 1) * pct);

async function checkForNew() {
  try {
    const res = await fetch(`/api/feed/updates?since=${newestId}`);
    if (!res.ok) throw new Error(res.status);
    const { items } = await res.json();

    if (items.length) {
      const seen = new Set(buffer.map((i) => i.id));
      buffer = [...items.filter((i) => !seen.has(i.id)), ...buffer];
      newestId = buffer[0].id;
      showPill(buffer.length); // "Show 12 posts"
    }
    failures = 0;
  } catch {
    failures++;
  }
  schedule();
}

function schedule() {
  clearTimeout(timer);
  if (document.hidden) return; // don't poll background tabs
  const backoff = Math.min(2 ** failures, 8);
  timer = setTimeout(checkForNew, jitter(BASE_MS * backoff));
}

document.addEventListener("visibilitychange", () => {
  if (document.hidden) clearTimeout(timer);
  else checkForNew(); // catch up immediately when the user returns
});

schedule();

Three details matter here:

  • Jitter keeps millions of clients from synchronizing and hitting your servers at the same instant.

  • Backoff stops a struggling server from being hammered by retries.

  • The Page Visibility API stops polling when nobody is looking at the tab, which saves server capacity and battery.

Step 4: Apply on user intent

When the user clicks the pill, render the buffer and scroll to the top:

function onPillClick() {
  renderAtTop(buffer); // prepend to the feed
  buffer = [];
  hidePill();
  window.scrollTo({ top: 0, behavior: "smooth" });
}

If you ever apply updates automatically, for example when the user is already at the very top, keep the scroll position stable so the page doesn't jump:

function prependKeepingPosition(items) {
  const before = document.documentElement.scrollHeight;
  renderAtTop(items);
  const delta = document.documentElement.scrollHeight - before;
  window.scrollBy(0, delta);
}

Some browsers handle this for you with CSS scroll anchoring, but support varies, so measuring and compensating is the portable approach.

Make the pill accessible

A silent update is invisible to screen reader users unless you announce it. Put the pill inside a polite live region:

<div role="status" aria-live="polite">
  <button id="pill" hidden>Show 12 posts</button>
</div>

Update the button text when the count changes. aria-live="polite" announces the change without interrupting whatever the user is reading.

The SSE version

If you want push instead of polling, the browser gives you most of it for free.

Client:

const es = new EventSource("/api/feed/stream");

es.addEventListener("new-posts", (e) => {
  const { items } = JSON.parse(e.data);
  buffer = [...items, ...buffer];
  showPill(buffer.length);
});

Server (Express style):

app.get("/api/feed/stream", (req, res) => {
  res.set({
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache, no-transform",
    Connection: "keep-alive",
    "X-Accel-Buffering": "no", // tell nginx not to buffer
  });
  res.flushHeaders();

  const send = (event, data, id) => {
    if (id) res.write(`id: ${id}\n`);
    res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
  };

  // `bus` stands in for your pub/sub layer (Redis, NATS, etc.)
  const unsubscribe = bus.subscribe(req.user.id, (items) =>
    send("new-posts", { items }, items[0].id)
  );

  const ping = setInterval(() => res.write(": ping\n\n"), 25_000);

  req.on("close", () => {
    clearInterval(ping);
    unsubscribe();
  });
});

EventSource reconnects automatically and sends a Last-Event-ID header on reconnect, so the server can replay anything the client missed. The periodic : ping comment keeps idle connections from being closed by proxies and load balancers.

Things that bite in production

  • Thundering herd. Without jitter, clients synchronize and spike your servers. Always randomize the interval.

  • Hidden tabs. Polling a tab nobody is looking at wastes capacity. Use the Page Visibility API.

  • Duplicates. Buffered items can overlap with a later fetch. Dedupe by ID.

  • Ranking. Algorithmic feeds don't sort purely by time, so the cursor may need to be a server-issued token.

  • Connection limits. HTTP/1.1 caps connections per origin, which can starve SSE streams. HTTP/2 largely removes this limit.

  • Proxy buffering. Reverse proxies can hold SSE responses until they fill a buffer. Disable buffering on the stream route.

  • Cost at scale. The cheapest check is one that returns a count or a 204 No Content, not a full payload.

  • Cache headers. An update endpoint that gets cached by a CDN or the browser will look like "no new posts" forever. Send Cache-Control: no-store.

See it yourself

Open DevTools, go to the Network tab, and filter by Fetch/XHR (plus EventStream for SSE). Leave a feed idle for two minutes. You'll see the real intervals, payload sizes, and any persistent streams. Because these sites change often, this is more reliable than anything written about them, including this post.

Wrapping up

The "silent refresh" is four small ideas working together: ask for deltas, buffer them off-screen, signal that something is waiting, and apply changes only when the user asks. Polling with jitter is enough for most products. Reach for SSE when you need lower latency and your infrastructure can hold long-lived connections.

If you've built something similar, or you've watched a real feed's traffic and seen something different from what I described, I'd like to hear about it in the comments.

Comments (0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

Sanju Singh

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Sanju Singh's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.