DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Building an X / Twitter feed from scratch: the timeline is a sorted array, likes are optimistic, and "5m ago" is arithmetic

A social feed looks like a lot of moving parts — timestamps ticking, hearts filling, new posts sliding in. When I built a working X / Twitter timeline in vanilla JS, it collapsed to one idea: the feed is just an array of post objects, and what's on screen is a pure function of that array. Change the array and redraw. That's it. Here's how the whole thing fits in about 120 lines, no library.

One post object holds everything

Everything the UI needs lives in a plain object. Identity fields render the header, text is the body, time is a millisecond timestamp, and each action carries both a count and a boolean — likes/liked, reposts/reposted, replies/replied — because a button must show a number and whether you have toggled it.

let uid = 1;
const posts = [
  { id: uid++, name:"Sarah Drasner", handle:"@sarah_edo", ini:"SD",
    grad:"linear-gradient(135deg,#6366f1,#a855f7)",
    text:"a timeline is just a sorted array.",
    time: Date.now() - 8_000,       // 8 seconds ago
    replies:12, replied:false, reposts:48, reposted:false, likes:302, liked:false },
  // …more, oldest last
];
Enter fullscreen mode Exit fullscreen mode

Render is a pure function of the array, newest-first

This is the heart of it. Sort a copy by time descending so the newest post is first — that one line is "reverse-chronological" — then map each post to a row and drop it into #feed. Because I redraw the whole list from state, there's never any stale DOM to reconcile by hand: change the data, call render, done.

function renderFeed(){
  const ordered = [...posts].sort((a, b) => b.time - a.time);  // newest first
  feed.innerHTML = ordered.map(postHTML).join("");
}
Enter fullscreen mode Exit fullscreen mode

Relative time: one number, not stored text

A feed never stores "3 minutes ago" as a string — it stores a timestamp and derives the label every time it draws, so it stays correct minutes later. Subtract the post's time from Date.now() and walk the units. A small setInterval re-derives every visible label every 10 seconds so "now" never lies.

function relTime(ts){
  const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
  if (s < 5)  return "now";
  if (s < 60) return s + "s";
  const m = Math.floor(s / 60); if (m < 60) return m + "m";
  const h = Math.floor(m / 60); if (h < 24) return h + "h";
  const d = Math.floor(h / 24); if (d < 7)  return d + "d";
  return new Date(ts).toLocaleDateString(undefined, { month:"short", day:"numeric" });
}
Enter fullscreen mode Exit fullscreen mode

Posting is prepend-then-sort

Publishing is pure data work: build a fresh object stamped with Date.now() and zeroed counters, then unshift it onto the array. Because render sorts by time, the newest post lands at the very top and the fade-in animation slides it in. That's the entire "reverse-chronological insert": add the newest item, sort, draw.

function publish(){
  const text = composer.value.trim();
  if (!text || text.length > MAX) return;
  posts.unshift({ id: uid++, name:"You", handle:"@you", ini:"YO", grad:MY_GRAD,
    text, time: Date.now(),
    replies:0, replied:false, reposts:0, reposted:false, likes:0, liked:false });
  composer.value = ""; renderFeed();
}
Enter fullscreen mode Exit fullscreen mode

Optimistic toggles: flip a flag, nudge a count

Liking should feel free, so I update the model immediately and trust it — that's what "optimistic" means. Find the post by id, flip its boolean, move its count by ±1 in the same direction, re-render. A real app would fire a request in the background and only roll this back if the server rejected it, but the instant local update is what makes the interaction feel weightless. One function serves like, repost, and reply.

const FIELD = { like:["liked","likes"], repost:["reposted","reposts"], reply:["replied","replies"] };
function toggle(id, kind){
  const p = posts.find(x => x.id === id); if (!p) return;
  const [flag, count] = FIELD[kind];
  p[flag]   = !p[flag];               // flip the boolean
  p[count] += p[flag] ? 1 : -1;       // optimistic ±1
  renderFeed();
}
Enter fullscreen mode Exit fullscreen mode

One delegated listener, and never trust the input

Rather than wire a listener onto every button in every row — which breaks the moment you re-render — I attach one click listener to #feed and let it delegate: find the clicked .act, read the row's id, call the shared toggle(). And because the compose box lets anyone type <script>, every user string is escaped before it touches innerHTML — five characters, one replacer, non-negotiable the moment real people can post.

feed.addEventListener("click", e => {
  const btn = e.target.closest(".act"); if (!btn) return;
  const id = +btn.closest(".post").dataset.id;
  toggle(id, btn.dataset.act);        // one handler for all rows + actions
});
function esc(s){ return String(s).replace(/[&<>"']/g, c =>
  ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c])); }
Enter fullscreen mode Exit fullscreen mode

Zoom out and every timeline you scroll — X, Bluesky, Mastodon, a comments thread, a chat log — is this same shape: a list of records in memory, sorted by time, rendered to a column, with each row's counters and its "3m ago" label computed from the record rather than stored. The whole feed is an array, a sort, a template, and one toggle().

Write a post, like it, watch the counts flip instantly:
https://dev48v.infy.uk/design/day38-twitter-feed.html

Top comments (0)