{"id":586,"date":"2024-12-16T07:04:02","date_gmt":"2024-12-16T07:04:02","guid":{"rendered":"https:\/\/mailitics.com\/index.php\/2024\/12\/16\/api-design-of-x-twitter-home-timeline-da426f19edfe\/"},"modified":"2024-12-16T07:04:02","modified_gmt":"2024-12-16T07:04:02","slug":"api-design-of-x-twitter-home-timeline-da426f19edfe","status":"publish","type":"post","link":"https:\/\/mailitics.com\/index.php\/2024\/12\/16\/api-design-of-x-twitter-home-timeline-da426f19edfe\/","title":{"rendered":"API Design of X (Twitter) Home Timeline"},"content":{"rendered":"<p>    API Design of X (Twitter) Home Timeline<br \/>\n \t<BR><br \/>\n<BR><\/BR><br \/>\n    <!-- no image --><br \/>\n \t<BR><br \/>\n<BR><\/BR><\/p>\n<div>\n<h3>How X (Twitter) Designed Its Home Timeline API: Lessons to\u00a0Learn<\/h3>\n<h4>A closer look at X\u2019s API: fetching data, linking entities, and solving under-fetching.<\/h4>\n<figure><img data-recalc-dims=\"1\" decoding=\"async\" alt=\"\" src=\"https:\/\/i0.wp.com\/cdn-images-1.medium.com\/max\/1024\/1%2Al-50r_ron0alhK-3H6lwuA.png?ssl=1\"><\/figure>\n<p>When designing a system\u2019s API, software engineers often evaluate various approaches, such as <a href=\"https:\/\/okso.app\/showcase\/system-design\/page\/0d03d895-b5b1-40c6-3549-945df9d98dcd\">REST vs RPC vs GraphQL<\/a>, or hybrid models, to determine the best fit for a specific task or project. These approaches define how data flows between the backend and frontend, as well as the structure of the response\u00a0data:<\/p>\n<ul>\n<li>Should all data be packed into a single \u201cbatch\u201d and returned in one response?<\/li>\n<li>Can the \u201cbatch\u201d be configured to include only the required fields for a specific client (e.g., browser vs. mobile) to avoid over-fetching?<\/li>\n<li>What happens if the client under-fetches data and requires additional backend calls to retrieve missing entities?<\/li>\n<li>How should parent-child relationships be handled? Should child entities be embedded within their parent, or should normalization be applied, where parent entities only reference child entity IDs to improve reusability and reduce response\u00a0size?<\/li>\n<\/ul>\n<p>In this article, we explore how the X (formerly Twitter) home timeline API (x.com\/home) addresses these challenges, including:<\/p>\n<ul>\n<li>Fetching the list of\u00a0tweets<\/li>\n<li>Returning hierarchical or linked data (e.g., tweets, users,\u00a0media)<\/li>\n<li>Sorting and paginating results<\/li>\n<li>Retrieving tweet\u00a0details<\/li>\n<li>Liking a\u00a0tweet<\/li>\n<\/ul>\n<p>Our focus will be on the API design and functionality, treating the backend as a black box since its implementation is inaccessible.<\/p>\n<figure><img data-recalc-dims=\"1\" decoding=\"async\" alt=\"\" src=\"https:\/\/i0.wp.com\/cdn-images-1.medium.com\/max\/1024\/0%2A_JPxQKlj1ky8Zx9H.jpg?ssl=1\"><figcaption>Example of X home\u00a0timeline<\/figcaption><\/figure>\n<blockquote><p><em>Showing the exact requests and responses here might be cumbersome and hard to follow since the deeply nested and repetitive objects are hard to read. To make it easier to see the request\/response payload structure, I\u2019ve made my attempt to \u201ctype out\u201d the home timeline API in TypeScript. So when it comes to the request\/response examples I\u2019ll use the request and response types instead of actual JSON objects. Also, remember that the types are simplified and many properties are omitted for\u00a0brevity.<\/em><\/p><\/blockquote>\n<blockquote><p>\n<em>You may find all types in <\/em><a href=\"https:\/\/github.com\/trekhleb\/trekhleb.github.io\/blob\/master\/src\/posts\/2024\/api-design-x-home-timeline\/types\/x.ts\"><em>types\/x.ts<\/em><\/a><em> file or at the bottom of this article in the \u201cAppendix: All types at one place\u201d\u00a0section.<\/em>\n<\/p><\/blockquote>\n<blockquote><p>All images, unless othewise noted, are by the\u00a0author.<\/p><\/blockquote>\n<h3>Fetching the list of\u00a0tweets<\/h3>\n<h3>The endpoint and request\/response structure<\/h3>\n<p>Fetching the list of tweets for the home timeline starts with the POST request to the following endpoint:<\/p>\n<pre>POST https:\/\/x.com\/i\/api\/graphql\/{query-id}\/HomeTimeline<\/pre>\n<p>Here is a simplified request body\u00a0type:<\/p>\n<pre>type TimelineRequest = {<br>  queryId: string; \/\/ 's6ERr1UxkxxBx4YundNsXw'<br>  variables: {<br>    count: number; \/\/ 20<br>    cursor?: string; \/\/ 'DAAACgGBGedb3Vx__9sKAAIZ5g4QENc99AcAAwAAIAIAAA'<br>    seenTweetIds: string[]; \/\/ ['1867041249938530657', '1867041249938530659']<br>  };<br>  features: Features;<br>};<br><br>type Features = {<br>  articles_preview_enabled: boolean;<br>  view_counts_everywhere_api_enabled: boolean;<br>  \/\/ ...<br>}<\/pre>\n<p>Here is a simplified response body type (we\u2019ll dive deeper into the response sub-types below):<\/p>\n<pre>type TimelineResponse = {<br>  data: {<br>    home: {<br>      home_timeline_urt: {<br>        instructions: (TimelineAddEntries | TimelineTerminateTimeline)[];<br>        responseObjects: {<br>          feedbackActions: TimelineAction[];<br>        };<br>      };<br>    };<br>  };<br>};<br><br>type TimelineAddEntries = {<br>  type: 'TimelineAddEntries';<br>  entries: (TimelineItem | TimelineCursor | TimelineModule)[];<br>};<br><br>type TimelineItem = {<br>  entryId: string; \/\/ 'tweet-1867041249938530657'<br>  sortIndex: string; \/\/ '1866561576636152411'<br>  content: {<br>    __typename: 'TimelineTimelineItem';<br>    itemContent: TimelineTweet;<br>    feedbackInfo: {<br>      feedbackKeys: ActionKey[]; \/\/ ['-1378668161']<br>    };<br>  };<br>};<br><br>type TimelineTweet = {<br>  __typename: 'TimelineTweet';<br>  tweet_results: {<br>    result: Tweet;<br>  };<br>};<br><br>type TimelineCursor = {<br>  entryId: string; \/\/ 'cursor-top-1867041249938530657'<br>  sortIndex: string; \/\/ '1866961576813152212'<br>  content: {<br>    __typename: 'TimelineTimelineCursor';<br>    value: string; \/\/ 'DACBCgABGedb4VyaJwuKbIIZ40cX3dYwGgaAAwAEAEEAA'<br>    cursorType: 'Top' | 'Bottom';<br>  };<br>};<br><br>type ActionKey = string;<\/pre>\n<p>It is interesting to note here, that \u201cgetting\u201d the data is done via \u201cPOSTing\u201d, which is not common for the REST-like API but it is common for a GraphQL-like API. Also, the graphql part of the URL indicates that X is using the GraphQL flavor for their\u00a0API.<\/p>\n<p>I\u2019m using the word <em>\u201cflavor\u201d<\/em> here because the request body itself doesn\u2019t look like a pure <a href=\"https:\/\/graphql.org\/learn\/queries\/\">GraphQL query<\/a>, where we may describe the required response structure, listing all the properties we want to\u00a0fetch:<\/p>\n<pre># An example of a pure GraphQL request structure that is *not* being used in the X API.<br>{<br>  tweets {<br>    id<br>    description<br>    created_at<br>    medias {<br>      kind<br>      url<br>      # ...<br>    }<br>    author {<br>      id<br>      name<br>      # ...<br>    }<br>    # ...<br>  }<br>}<\/pre>\n<p>The assumption here is that the home timeline API is not a pure GraphQL API, but is a mix of several approaches. Passing the parameters in a POST request like this seems closer to the \u201cfunctional\u201d RPC call. But at the same time, it seems like the GraphQL features might be used somewhere on the backend behind the <em>HomeTimeline<\/em> endpoint handler\/controller. A mix like this might also be caused by a legacy code or some sort of ongoing migration. But again, these are just my speculations.<\/p>\n<p>You may also notice that the same TimelineRequest.queryId is used in the API URL as well as in the API request body. This queryId is most probably generated on the backend, then it gets embedded in the main.js bundle, and then it is used when fetching the data from the backend. It is hard for me to understand how this queryId is used exactly since X&#8217;s backend is a black box in our case. But, again, the speculation here might be that, it might be needed for some sort of performance optimization (re-using some pre-computed query results?), caching (Apollo related?), debugging (join logs by queryId?), or tracking\/tracing purposes.<\/p>\n<p>It is also interesting to note, that the TimelineResponse contains not a list of tweets, but rather a list of instructions, like <em>&#8220;add a tweet to the timeline&#8221;<\/em> (see the TimelineAddEntries type), or <em>&#8220;terminate the timeline&#8221;<\/em> (see the TimelineTerminateTimeline type).<\/p>\n<p>The TimelineAddEntries instruction itself may also contain different types of entities:<\/p>\n<ul>\n<li>Tweets\u200a\u2014\u200asee the TimelineItem type<\/li>\n<li>Cursors\u200a\u2014\u200asee the TimelineCursor type<\/li>\n<li>Conversations\/comments\/threads\u200a\u2014\u200asee the TimelineModule type<\/li>\n<\/ul>\n<pre>type TimelineResponse = {<br>  data: {<br>    home: {<br>      home_timeline_urt: {<br>        instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; \/\/ &lt;-- Here<br>        \/\/ ...<br>      };<br>    };<br>  };<br>};<br><br>type TimelineAddEntries = {<br>  type: 'TimelineAddEntries';<br>  entries: (TimelineItem | TimelineCursor | TimelineModule)[]; \/\/ &lt;-- Here<br>};<\/pre>\n<p>This is interesting from the extendability point of view since it allows a wider variety of what can be rendered in the home timeline without tweaking the API too\u00a0much.<\/p>\n<h3>Pagination<\/h3>\n<p>The TimelineRequest.variables.count property sets how many tweets we want to fetch at once (per page). The default is 20. However, more than 20 tweets can be returned in the TimelineAddEntries.entries array. For example, the array might contain 37 entries for the first page load, because it includes tweets (29), pinned tweets (1), promoted tweets (5), and pagination cursors (2). I&#8217;m not sure why there are 29 regular tweets with the requested count of 20\u00a0though.<\/p>\n<p>The TimelineRequest.variables.cursor is responsible for the cursor-based pagination.<\/p>\n<blockquote><p>\n<em>\u201cCursor pagination is most often used for real-time data due to the frequency new records are added and because when reading data you often see the latest results first. It eliminates the possibility of skipping items and displaying the same item more than once. In cursor-based pagination, a constant pointer (or cursor) is used to keep track of where in the data set the next items should be fetched from.\u201d See the <\/em><a href=\"https:\/\/stackoverflow.com\/questions\/55744926\/offset-pagination-vs-cursor-pagination\"><em>Offset pagination vs Cursor pagination<\/em><\/a><em> thread for the\u00a0context.<\/em>\n<\/p><\/blockquote>\n<p>When fetching the list of tweets for the first time the TimelineRequest.variables.cursor is empty, since we want to fetch the top tweets from the default (most probably pre-computed) list of personalized tweets.<\/p>\n<p>However, in the response, along with the tweet data, the backend also returns the cursor entries. Here is the response type hierarchy: TimelineResponse \u2192 TimelineAddEntries \u2192 TimelineCursor:<\/p>\n<pre>type TimelineResponse = {<br>  data: {<br>    homet: {<br>      home_timeline_urt: {<br>        instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; \/\/ &lt;-- Here<br>        \/\/ ...<br>      };<br>    };<br>  };<br>};<br><br>type TimelineAddEntries = {<br>  type: 'TimelineAddEntries';<br>  entries: (TimelineItem | TimelineCursor | TimelineModule)[]; \/\/ &lt;-- Here (tweets + cursors)<br>};<br><br>type TimelineCursor = {<br>  entryId: string;<br>  sortIndex: string;<br>  content: {<br>    __typename: 'TimelineTimelineCursor';<br>    value: string; \/\/ 'DACBCgABGedb4VyaJwuKbIIZ40cX3dYwGgaAAwAEAEEAA' &lt;-- Here<br>    cursorType: 'Top' | 'Bottom';<br>  };<br>};<\/pre>\n<p>Every page contains the list of tweets along with \u201ctop\u201d and \u201cbottom\u201d\u00a0cursors:<\/p>\n<figure><img data-recalc-dims=\"1\" decoding=\"async\" alt=\"\" src=\"https:\/\/i0.wp.com\/cdn-images-1.medium.com\/max\/1024\/0%2A9HwhCJfskn1lZWJn.jpg?ssl=1\"><figcaption>Examples of how cursors are passed along with\u00a0tweets<\/figcaption><\/figure>\n<p>After the page data is loaded, we can go from the current page in both directions and fetch either the \u201cprevious\/older\u201d tweets using the \u201cbottom\u201d cursor or the \u201cnext\/newer\u201d tweets using the \u201ctop\u201d cursor. My assumption is that fetching the \u201cnext\u201d tweets using the \u201ctop\u201d cursor happens in two cases: when the new tweets were added while the user is still reading the current page, or when the user starts scrolling the feed upwards (and there are no cached entries or if the previous entries were deleted for the performance reasons).<\/p>\n<p>The X\u2019s cursor itself might look like this: DAABCgABGemI6Mk__9sKAAIZ6MSYG9fQGwgAAwAAAAIAAA. In some API designs, the cursor may be a Base64 encoded string that contains the id of the last entry in the list, or the timestamp of the last seen entry. For example: eyJpZCI6ICIxMjM0NTY3ODkwIn0= &#8211;&gt; {&#8220;id&#8221;: &#8220;1234567890&#8221;}, and then, this data is used to query the database accordingly. In the case of X API, it looks like the cursor is being Base64 decoded into some custom binary sequence that might require some further decoding to get any meaning out of it (i.e. via the Protobuf message definitions). Since we don&#8217;t know if it is a\u00a0.proto encoding and also we don&#8217;t know the\u00a0.proto message definition we may just assume that the backend knows how to query the next batch of tweets based on the cursor\u00a0string.<\/p>\n<p>The TimelineResponse.variables.seenTweetIds parameter is used to inform the server about which tweets from the currently active page of the infinite scrolling the client has already seen. This most probably helps ensure that the server does not include duplicate tweets in subsequent pages of\u00a0results.<\/p>\n<h3>Linked\/hierarchical entities<\/h3>\n<p>One of the challenges to be solved in the APIs like home timeline (or Home Feed) is to figure out how to return the linked or hierarchical entities (i.e. tweet \u2192 user, tweet \u2192 media, media \u2192 author,\u00a0etc):<\/p>\n<ul>\n<li>Should we only return the list of tweets first and then fetch the dependent entities (like user details) in a bunch of separate queries on-demand?<\/li>\n<li>Or should we return all the data at once, increasing the time and the size of the first load, but saving the time for all subsequent calls?<\/li>\n<li>Do we need to normalize the data in this case to reduce the payload size (i.e. when the same user is an author of many tweets and we want to avoid repeating the user data over and over again in each tweet\u00a0entity)?<\/li>\n<li>Or should it be a combination of the approaches above?<\/li>\n<\/ul>\n<p>Let\u2019s see how X handles\u00a0it.<\/p>\n<p>Earlier in the TimelineTweet type the Tweet sub-type was used. Let&#8217;s see how it\u00a0looks:<\/p>\n<pre>export type TimelineResponse = {<br>  data: {<br>    home: {<br>      home_timeline_urt: {<br>        instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; \/\/ &lt;-- Here<br>        \/\/ ...<br>      };<br>    };<br>  };<br>};<br><br>type TimelineAddEntries = {<br>  type: 'TimelineAddEntries';<br>  entries: (TimelineItem | TimelineCursor | TimelineModule)[]; \/\/ &lt;-- Here<br>};<br><br>type TimelineItem = {<br>  entryId: string;<br>  sortIndex: string;<br>  content: {<br>    __typename: 'TimelineTimelineItem';<br>    itemContent: TimelineTweet; \/\/ &lt;-- Here<br>    \/\/ ...<br>  };<br>};<br><br>type TimelineTweet = {<br>  __typename: 'TimelineTweet';<br>  tweet_results: {<br>    result: Tweet; \/\/ &lt;-- Here<br>  };<br>};<br><br>\/\/ A Tweet entity<br>type Tweet = {<br>  __typename: 'Tweet';<br>  core: {<br>    user_results: {<br>      result: User; \/\/ &lt;-- Here (a dependent User entity)<br>    };<br>  };<br>  legacy: {<br>    full_text: string;<br>    \/\/ ...<br>    entities: { \/\/ &lt;-- Here (a dependent Media entities)<br>      media: Media[];<br>      hashtags: Hashtag[];<br>      urls: Url[];<br>      user_mentions: UserMention[];<br>    };<br>  };<br>};<br><br>\/\/ A User entity<br>type User = {<br>  __typename: 'User';<br>  id: string; \/\/ 'VXNlcjoxNDUxM4ADSG44MTA4NDc4OTc2'<br>  \/\/ ...<br>  legacy: {<br>    location: string; \/\/ 'San Francisco'<br>    name: string; \/\/  'John Doe'<br>    \/\/ ...<br>  };<br>};<br><br>\/\/ A Media entity<br>type Media = {<br>  \/\/ ...<br>  source_user_id_str: string; \/\/ '1867041249938530657'  &lt;-- Here (the dependant user is being mentioned by its ID)<br>  url: string; \/\/ 'https:\/\/t.co\/X78dBgtrsNU'<br>  features: {<br>    large: { faces: FaceGeometry[] };<br>    medium: { faces: FaceGeometry[] };<br>    small: { faces: FaceGeometry[] };<br>    orig: { faces: FaceGeometry[] };<br>  };<br>  sizes: {<br>    large: MediaSize;<br>    medium: MediaSize;<br>    small: MediaSize;<br>    thumb: MediaSize;<br>  };<br>  video_info: VideoInfo[];<br>};<\/pre>\n<p>What\u2019s interesting here is that most of the dependent data like tweet \u2192 media and tweet \u2192 author is embedded into the response on the first call (no subsequent queries).<\/p>\n<p>Also, the User and Media connections with Tweet entities are not normalized (if two tweets have the same author, their data will be repeated in each tweet object). But it seems like it should be ok, since in the scope of the home timeline for a specific user the tweets will be authored by many authors and repetitions are possible but\u00a0sparse.<\/p>\n<p>My assumption was that the UserTweets API (that we don&#8217;t cover here), which is responsible for fetching the tweets of <em>one particular user<\/em> will handle it differently, but, apparently, it is not the case. The UserTweets returns the list of tweets of the same user and embeds the same user data over and over again for each tweet. It&#8217;s interesting. Maybe the simplicity of the approach beats some data size overhead (maybe user data is considered pretty small in size). I&#8217;m not\u00a0sure.<\/p>\n<p>Another observation about the entities\u2019 relationship is that the Media entity also has a link to the User (the author). But it does it not via direct entity embedding as the Tweet entity does, but rather it links via the Media.source_user_id_str property.<\/p>\n<p>The \u201ccomments\u201d (which are also the \u201ctweets\u201d by their nature) for each \u201ctweet\u201d in the home timeline are not fetched at all. To see the tweet thread the user must click on the tweet to see its detailed view. The tweet thread will be fetched by calling the TweetDetail endpoint (more about it in the &#8220;Tweet detail page&#8221; section\u00a0below).<\/p>\n<p>Another entity that each Tweet has is FeedbackActions (i.e. &#8220;Recommend less often&#8221; or &#8220;See fewer&#8221;). The way the FeedbackActions are stored in the response object is different from the way the User and Media objects are stored. While the User and Media entities are part of the Tweet, the FeedbackActions are stored separately in TimelineItem.content.feedbackInfo.feedbackKeys array and are linked via the ActionKey. That was a slight surprise for me since it doesn&#8217;t seem to be the case that any action is re-usable. It looks like one action is used for one particular tweet only. So it seems like the FeedbackActions could be embedded into each tweet in the same way as Media entities. But I might be missing some hidden complexity here (like the fact that each action can have children actions).<\/p>\n<p>More details about the actions are in the \u201cTweet actions\u201d section\u00a0below.<\/p>\n<h3>Sorting<\/h3>\n<p>The sorting order of the timeline entries is defined by the backend via the sortIndex properties:<\/p>\n<pre>type TimelineCursor = {<br>  entryId: string;<br>  sortIndex: string; \/\/ '1866961576813152212' &lt;-- Here<br>  content: {<br>    __typename: 'TimelineTimelineCursor';<br>    value: string;<br>    cursorType: 'Top' | 'Bottom';<br>  };<br>};<br><br>type TimelineItem = {<br>  entryId: string;<br>  sortIndex: string; \/\/ '1866561576636152411' &lt;-- Here<br>  content: {<br>    __typename: 'TimelineTimelineItem';<br>    itemContent: TimelineTweet;<br>    feedbackInfo: {<br>      feedbackKeys: ActionKey[];<br>    };<br>  };<br>};<br><br>type TimelineModule = {<br>  entryId: string;<br>  sortIndex: string; \/\/ '73343543020642838441' &lt;-- Here<br>  content: {<br>    __typename: 'TimelineTimelineModule';<br>    items: {<br>      entryId: string,<br>      item: TimelineTweet,<br>    }[],<br>    displayType: 'VerticalConversation',<br>  };<br>};<\/pre>\n<p>The sortIndex itself might look something like this &#8216;1867231621095096312&#8217;. It likely corresponds directly to or is derived from a <a href=\"https:\/\/en.wikipedia.org\/wiki\/Snowflake_ID\">Snowflake ID<\/a>.<\/p>\n<blockquote><p>\n<em>Actually most of the IDs you see in the response (tweet IDs) follow the \u201cSnowflake ID\u201d convention and look like <\/em><em>&#8216;1867231621095096312&#8217;.<\/em>\n<\/p><\/blockquote>\n<p>If this is used to sort entities like tweets, the system leverages the inherent chronological sorting of Snowflake IDs. Tweets or objects with a higher sortIndex value (a more recent timestamp) appear higher in the feed, while those with lower values (an older timestamp) appear lower in the\u00a0feed.<\/p>\n<p>Here\u2019s the step-by-step decoding of the Snowflake ID (in our case the sortIndex) 1867231621095096312:<\/p>\n<ul>\n<li><strong>Extract the Timestamp:<\/strong><\/li>\n<li>The timestamp is derived by right-shifting the Snowflake ID by 22 bits (to remove the lower 22 bits for data center, worker ID, and sequence): 1867231621095096312 \u2192 445182709954<\/li>\n<li><strong>Add Twitter\u2019s Epoch:<\/strong><\/li>\n<li>Adding Twitter\u2019s custom epoch (1288834974657) to this timestamp gives the UNIX timestamp in milliseconds: 445182709954 + 1288834974657 \u2192 1734017684611ms<\/li>\n<li><strong>Convert to a human-readable date:<\/strong><\/li>\n<li>Converting the UNIX timestamp to a UTC datetime gives: 1734017684611ms \u2192 2024-12-12 15:34:44.611 (UTC)<\/li>\n<\/ul>\n<p>So we can assume here that the tweets in the home timeline are sorted chronologically.<\/p>\n<h3>Tweet actions<\/h3>\n<p>Each tweet has an \u201cActions\u201d menu.<\/p>\n<figure><img data-recalc-dims=\"1\" decoding=\"async\" alt=\"\" src=\"https:\/\/i0.wp.com\/cdn-images-1.medium.com\/max\/1024\/0%2A0VBHFdxETXH_mggz.jpg?ssl=1\"><figcaption>Example of tweet\u00a0actions<\/figcaption><\/figure>\n<p>The actions for each tweet are coming from the backend in a TimelineItem.content.feedbackInfo.feedbackKeys array and are linked with the tweets via the ActionKey:<\/p>\n<pre>type TimelineResponse = {<br>  data: {<br>    home: {<br>      home_timeline_urt: {<br>        instructions: (TimelineAddEntries | TimelineTerminateTimeline)[];<br>        responseObjects: {<br>          feedbackActions: TimelineAction[]; \/\/ &lt;-- Here<br>        };<br>      };<br>    };<br>  };<br>};<br><br>type TimelineItem = {<br>  entryId: string;<br>  sortIndex: string;<br>  content: {<br>    __typename: 'TimelineTimelineItem';<br>    itemContent: TimelineTweet;<br>    feedbackInfo: {<br>      feedbackKeys: ActionKey[]; \/\/ ['-1378668161'] &lt;-- Here<br>    };<br>  };<br>};<br><br>type TimelineAction = {<br>  key: ActionKey; \/\/ '-609233128'<br>  value: {<br>    feedbackType: 'NotRelevant' | 'DontLike' | 'SeeFewer'; \/\/ ...<br>    prompt: string; \/\/ 'This post isn\u2019t relevant' | 'Not interested in this post' | ...<br>    confirmation: string; \/\/ 'Thanks. You\u2019ll see fewer posts like this.'<br>    childKeys: ActionKey[]; \/\/ ['1192182653', '-1427553257'], i.e. NotInterested -&gt; SeeFewer<br>    feedbackUrl: string; \/\/ '\/2\/timeline\/feedback.json?feedback_type=NotRelevant&amp;action_metadata=SRwW6oXZadPHiOczBBaAwPanEwE%3D'<br>    hasUndoAction: boolean;<br>    icon: string; \/\/ 'Frown'<br>  };<br>};<\/pre>\n<p>It is interesting here that this flat array of actions is actually a tree (or a graph? I didn\u2019t check), since each action may have child actions (see the TimelineAction.value.childKeys array). This makes sense, for example, when after the user clicks on the &#8220;Don&#8217;t Like&#8221; action, the follow-up might be to show the &#8220;This post isn\u2019t relevant&#8221; action, as a way of explaining why the user doesn&#8217;t like the\u00a0tweet.<\/p>\n<h3>Tweet detail\u00a0page<\/h3>\n<p>Once the user would like to see the tweet detail page (i.e. to see the thread of comments\/tweets), the user clicks on the tweet and the GET request to the following endpoint is performed:<\/p>\n<pre>GET https:\/\/x.com\/i\/api\/graphql\/{query-id}\/TweetDetail?variables={\"focalTweetId\":\"1867231621095096312\",\"referrer\":\"home\",\"controller_data\":\"DACABBSQ\",\"rankingMode\":\"Relevance\",\"includePromotedContent\":true,\"withCommunity\":true}&amp;features={\"articles_preview_enabled\":true}<\/pre>\n<p>I was curious here why the list of tweets is being fetched via the POST call, but each tweet detail is fetched via the GET call. Seems inconsistent. Especially keeping in mind that similar query parameters like query-id, features, and others this time are passed in the URL and not in the request body. The response format is also similar and is re-using the types from the list call. I&#8217;m not sure why is that. But again, I&#8217;m sure I might be might be missing some background complexity here.<\/p>\n<p>Here are the simplified response body\u00a0types:<\/p>\n<pre>type TweetDetailResponse = {<br>  data: {<br>    threaded_conversation_with_injections_v2: {<br>      instructions: (TimelineAddEntries | TimelineTerminateTimeline)[],<br>    },<br>  },<br>}<br><br>type TimelineAddEntries = {<br>  type: 'TimelineAddEntries';<br>  entries: (TimelineItem | TimelineCursor | TimelineModule)[];<br>};<br><br>type TimelineTerminateTimeline = {<br>  type: 'TimelineTerminateTimeline',<br>  direction: 'Top',<br>}<br><br>type TimelineModule = {<br>  entryId: string; \/\/ 'conversationthread-58668734545929871193'<br>  sortIndex: string; \/\/ '1867231621095096312'<br>  content: {<br>    __typename: 'TimelineTimelineModule';<br>    items: {<br>      entryId: string, \/\/ 'conversationthread-1866876425669871193-tweet-1866876038930951193'<br>      item: TimelineTweet,<br>    }[], \/\/ Comments to the tweets are also tweets<br>    displayType: 'VerticalConversation',<br>  };<br>};<\/pre>\n<p>The response is pretty similar (in its types) to the list response, so we won\u2019t for too long\u00a0here.<\/p>\n<p>One interesting nuance is that the \u201ccomments\u201d (or conversations) of each tweet are actually other tweets (see the TimelineModule type). So the tweet thread looks very similar to the home timeline feed by showing the list of TimelineTweet entries. This looks elegant. A good example of a universal and re-usable approach to the API\u00a0design.<\/p>\n<h3>Liking the\u00a0tweet<\/h3>\n<p>When a user likes the tweet, the POST request to the following endpoint is being performed:<\/p>\n<pre>POST https:\/\/x.com\/i\/api\/graphql\/{query-id}\/FavoriteTweet<\/pre>\n<p>Here is the request body\u00a0types:<\/p>\n<pre>type FavoriteTweetRequest = {<br>  variables: {<br>    tweet_id: string; \/\/ '1867041249938530657'<br>  };<br>  queryId: string; \/\/ 'lI07N61twFgted2EgXILM7A'<br>};<\/pre>\n<p>Here is the response body\u00a0types:<\/p>\n<pre>type FavoriteTweetResponse = {<br>  data: {<br>    favorite_tweet: 'Done',<br>  }<br>}<\/pre>\n<p>Looks straightforward and also resembles the RPC-like approach to the API\u00a0design.<\/p>\n<h3>Conclusion<\/h3>\n<p>We have touched on some basic parts of the home timeline API design by looking at X\u2019s API example. I made some assumptions along the way to the best of my knowledge. I believe some things I might have interpreted incorrectly and I might have missed some complex nuances. But even with that in mind, I hope you got some useful insights from this high-level overview, something that you could apply in your next API Design\u00a0session.<\/p>\n<p>Initially, I had a plan to go through similar top-tech websites to get some insights from Facebook, Reddit, YouTube, and others and to collect battle-tested best practices and solutions. I\u2019m not sure if I\u2019ll find the time to do that. Will see. But it could be an interesting exercise.<\/p>\n<h3>Appendix: All types in one\u00a0place<\/h3>\n<p>For the reference, I\u2019m adding all types in one go here. You may also find all types in <a href=\"https:\/\/github.com\/trekhleb\/trekhleb.github.io\/blob\/master\/src\/posts\/2024\/api-design-x-home-timeline\/types\/x.ts\">types\/x.ts<\/a> file.<\/p>\n<pre>\/**<br> * This file contains the simplified types for X's (Twitter's) home timeline API.<br> *<br> * These types are created for exploratory purposes, to see the current implementation<br> * of the X's API, to see how they fetch Home Feed, how they do a pagination and sorting,<br> * and how they pass the hierarchical entities (posts, media, user info, etc).<br> *<br> * Many properties and types are omitted for simplicity.<br> *\/<br><br>\/\/ POST https:\/\/x.com\/i\/api\/graphql\/{query-id}\/HomeTimeline<br>export type TimelineRequest = {<br>  queryId: string; \/\/ 's6ERr1UxkxxBx4YundNsXw'<br>  variables: {<br>    count: number; \/\/ 20<br>    cursor?: string; \/\/ 'DAAACgGBGedb3Vx__9sKAAIZ5g4QENc99AcAAwAAIAIAAA'<br>    seenTweetIds: string[]; \/\/ ['1867041249938530657', '1867041249938530658']<br>  };<br>  features: Features;<br>};<br><br>\/\/ POST https:\/\/x.com\/i\/api\/graphql\/{query-id}\/HomeTimeline<br>export type TimelineResponse = {<br>  data: {<br>    home: {<br>      home_timeline_urt: {<br>        instructions: (TimelineAddEntries | TimelineTerminateTimeline)[];<br>        responseObjects: {<br>          feedbackActions: TimelineAction[];<br>        };<br>      };<br>    };<br>  };<br>};<br><br>\/\/ POST https:\/\/x.com\/i\/api\/graphql\/{query-id}\/FavoriteTweet<br>export type FavoriteTweetRequest = {<br>  variables: {<br>    tweet_id: string; \/\/ '1867041249938530657'<br>  };<br>  queryId: string; \/\/ 'lI07N6OtwFgted2EgXILM7A'<br>};<br><br>\/\/ POST https:\/\/x.com\/i\/api\/graphql\/{query-id}\/FavoriteTweet<br>export type FavoriteTweetResponse = {<br>  data: {<br>    favorite_tweet: 'Done',<br>  }<br>}<br><br>\/\/ GET https:\/\/x.com\/i\/api\/graphql\/{query-id}\/TweetDetail?variables={\"focalTweetId\":\"1867041249938530657\",\"referrer\":\"home\",\"controller_data\":\"DACABBSQ\",\"rankingMode\":\"Relevance\",\"includePromotedContent\":true,\"withCommunity\":true}&amp;features={\"articles_preview_enabled\":true}<br>export type TweetDetailResponse = {<br>  data: {<br>    threaded_conversation_with_injections_v2: {<br>      instructions: (TimelineAddEntries | TimelineTerminateTimeline)[],<br>    },<br>  },<br>}<br><br>type Features = {<br>  articles_preview_enabled: boolean;<br>  view_counts_everywhere_api_enabled: boolean;<br>  \/\/ ...<br>}<br><br>type TimelineAction = {<br>  key: ActionKey; \/\/ '-609233128'<br>  value: {<br>    feedbackType: 'NotRelevant' | 'DontLike' | 'SeeFewer'; \/\/ ...<br>    prompt: string; \/\/ 'This post isn\u2019t relevant' | 'Not interested in this post' | ...<br>    confirmation: string; \/\/ 'Thanks. You\u2019ll see fewer posts like this.'<br>    childKeys: ActionKey[]; \/\/ ['1192182653', '-1427553257'], i.e. NotInterested -&gt; SeeFewer<br>    feedbackUrl: string; \/\/ '\/2\/timeline\/feedback.json?feedback_type=NotRelevant&amp;action_metadata=SRwW6oXZadPHiOczBBaAwPanEwE%3D'<br>    hasUndoAction: boolean;<br>    icon: string; \/\/ 'Frown'<br>  };<br>};<br><br>type TimelineAddEntries = {<br>  type: 'TimelineAddEntries';<br>  entries: (TimelineItem | TimelineCursor | TimelineModule)[];<br>};<br><br>type TimelineTerminateTimeline = {<br>  type: 'TimelineTerminateTimeline',<br>  direction: 'Top',<br>}<br><br>type TimelineCursor = {<br>  entryId: string; \/\/ 'cursor-top-1867041249938530657'<br>  sortIndex: string; \/\/ '1867231621095096312'<br>  content: {<br>    __typename: 'TimelineTimelineCursor';<br>    value: string; \/\/ 'DACBCgABGedb4VyaJwuKbIIZ40cX3dYwGgaAAwAEAEEAA'<br>    cursorType: 'Top' | 'Bottom';<br>  };<br>};<br><br>type TimelineItem = {<br>  entryId: string; \/\/ 'tweet-1867041249938530657'<br>  sortIndex: string; \/\/ '1867231621095096312'<br>  content: {<br>    __typename: 'TimelineTimelineItem';<br>    itemContent: TimelineTweet;<br>    feedbackInfo: {<br>      feedbackKeys: ActionKey[]; \/\/ ['-1378668161']<br>    };<br>  };<br>};<br><br>type TimelineModule = {<br>  entryId: string; \/\/ 'conversationthread-1867041249938530657'<br>  sortIndex: string; \/\/ '1867231621095096312'<br>  content: {<br>    __typename: 'TimelineTimelineModule';<br>    items: {<br>      entryId: string, \/\/ 'conversationthread-1867041249938530657-tweet-1867041249938530657'<br>      item: TimelineTweet,<br>    }[], \/\/ Comments to the tweets are also tweets<br>    displayType: 'VerticalConversation',<br>  };<br>};<br><br>type TimelineTweet = {<br>  __typename: 'TimelineTweet';<br>  tweet_results: {<br>    result: Tweet;<br>  };<br>};<br><br>type Tweet = {<br>  __typename: 'Tweet';<br>  core: {<br>    user_results: {<br>      result: User;<br>    };<br>  };<br>  views: {<br>    count: string; \/\/ '13763'<br>  };<br>  legacy: {<br>    bookmark_count: number; \/\/ 358<br>    created_at: string; \/\/ 'Tue Dec 10 17:41:28 +0000 2024'<br>    conversation_id_str: string; \/\/ '1867041249938530657'<br>    display_text_range: number[]; \/\/ [0, 58]<br>    favorite_count: number; \/\/ 151<br>    full_text: string; \/\/  \"How I'd promote my startup, if I had 0 followers (Part 1)\"<br>    lang: string; \/\/ 'en'<br>    quote_count: number;<br>    reply_count: number;<br>    retweet_count: number;<br>    user_id_str: string; \/\/ '1867041249938530657'<br>    id_str: string; \/\/ '1867041249938530657'<br>    entities: {<br>      media: Media[];<br>      hashtags: Hashtag[];<br>      urls: Url[];<br>      user_mentions: UserMention[];<br>    };<br>  };<br>};<br><br>type User = {<br>  __typename: 'User';<br>  id: string; \/\/ 'VXNlcjoxNDUxM4ADSG44MTA4NDc4OTc2'<br>  rest_id: string; \/\/ '1867041249938530657'<br>  is_blue_verified: boolean;<br>  profile_image_shape: 'Circle'; \/\/ ...<br>  legacy: {<br>    following: boolean;<br>    created_at: string; \/\/ 'Thu Oct 21 09:30:37 +0000 2021'<br>    description: string; \/\/ 'I help startup founders double their MRR with outside-the-box marketing cheat sheets'<br>    favourites_count: number; \/\/ 22195<br>    followers_count: number; \/\/ 25658<br>    friends_count: number;<br>    location: string; \/\/ 'San Francisco'<br>    media_count: number;<br>    name: string; \/\/  'John Doe'<br>    profile_banner_url: string; \/\/ 'https:\/\/pbs.twimg.com\/profile_banners\/4863509452891265813\/4863509'<br>    profile_image_url_https: string; \/\/ 'https:\/\/pbs.twimg.com\/profile_images\/4863509452891265813\/4863509_normal.jpg'<br>    screen_name: string; \/\/ 'johndoe'<br>    url: string; \/\/ 'https:\/\/t.co\/dgTEddFGDd'<br>    verified: boolean;<br>  };<br>};<br><br>type Media = {<br>  display_url: string; \/\/ 'pic.x.com\/X7823zS3sNU'<br>  expanded_url: string; \/\/ 'https:\/\/x.com\/johndoe\/status\/1867041249938530657\/video\/1'<br>  ext_alt_text: string; \/\/ 'Image of two bridges.'<br>  id_str: string; \/\/ '1867041249938530657'<br>  indices: number[]; \/\/ [93, 116]<br>  media_key: string; \/\/ '13_2866509231399826944'<br>  media_url_https: string; \/\/ 'https:\/\/pbs.twimg.com\/profile_images\/1867041249938530657\/4863509_normal.jpg'<br>  source_status_id_str: string; \/\/ '1867041249938530657'<br>  source_user_id_str: string; \/\/ '1867041249938530657'<br>  type: string; \/\/ 'video'<br>  url: string; \/\/ 'https:\/\/t.co\/X78dBgtrsNU'<br>  features: {<br>    large: { faces: FaceGeometry[] };<br>    medium: { faces: FaceGeometry[] };<br>    small: { faces: FaceGeometry[] };<br>    orig: { faces: FaceGeometry[] };<br>  };<br>  sizes: {<br>    large: MediaSize;<br>    medium: MediaSize;<br>    small: MediaSize;<br>    thumb: MediaSize;<br>  };<br>  video_info: VideoInfo[];<br>};<br><br>type UserMention = {<br>  id_str: string; \/\/ '98008038'<br>  name: string; \/\/ 'Yann LeCun'<br>  screen_name: string; \/\/ 'ylecun'<br>  indices: number[]; \/\/ [115, 122]<br>};<br><br>type Hashtag = {<br>  indices: number[]; \/\/ [257, 263]<br>  text: string;<br>};<br><br>type Url = {<br>  display_url: string; \/\/ 'google.com'<br>  expanded_url: string; \/\/ 'http:\/\/google.com'<br>  url: string; \/\/ 'https:\/\/t.co\/nZh3aF0Aw6'<br>  indices: number[]; \/\/ [102, 125]<br>};<br><br>type VideoInfo = {<br>  aspect_ratio: number[]; \/\/ [427, 240]<br>  duration_millis: number; \/\/ 20000<br>  variants: {<br>    bitrate?: number; \/\/ 288000<br>    content_type?: string; \/\/ 'application\/x-mpegURL' | 'video\/mp4' | ...<br>    url: string; \/\/ 'https:\/\/video.twimg.com\/amplify_video\/18665094345456w6944\/pl\/-ItQau_LRWedR-W7.m3u8?tag=14'<br>  };<br>};<br><br>type FaceGeometry = { x: number; y: number; h: number; w: number };<br><br>type MediaSize = { h: number; w: number; resize: 'fit' | 'crop' };<br><br>type ActionKey = string;<\/pre>\n<p><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/medium.com\/_\/stat?event=post.clientViewed&amp;referrerSource=full_rss&amp;postId=da426f19edfe\" width=\"1\" height=\"1\" alt=\"\"><\/p>\n<hr>\n<p><a href=\"https:\/\/towardsdatascience.com\/api-design-of-x-twitter-home-timeline-da426f19edfe\">API Design of X (Twitter) Home Timeline<\/a> was originally published in <a href=\"https:\/\/towardsdatascience.com\/\">Towards Data Science<\/a> on Medium, where people are continuing the conversation by highlighting and responding to this story.<\/p>\n<\/div>\n<p> \t<BR><br \/>\n <BR><\/BR><br \/>\n    Oleksii Trekhleb<br \/>\n \t<BR><br \/>\n<BR><\/BR><br \/>\n<a href=\"https:\/\/medium.com\/m\/global-identity-2?redirectUrl=https%3A%2F%2Ftowardsdatascience.com%2Fapi-design-of-x-twitter-home-timeline-da426f19edfe\">Go to original source<\/a><br \/>\n \t<BR><br \/>\n <BR><\/BR><\/p>\n","protected":false},"excerpt":{"rendered":"<p>API Design of X (Twitter) Home Timeline How X (Twitter) Designed Its Home Timeline API: Lessons to\u00a0Learn A closer look at X\u2019s API: fetching data, linking entities, and solving under-fetching. When designing a system\u2019s API, software engineers often evaluate various approaches, such as REST vs RPC vs GraphQL, or hybrid models, to determine the best [&hellip;]<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[62,49,699,698,701,700],"tags":[702,84,30],"class_list":["post-586","post","type-post","status-publish","format-standard","hentry","category-aimldsaimlds","category-engineering","category-software-development","category-system-design-interview","category-web","category-webdev","tag-api","tag-data","tag-home"],"_links":{"self":[{"href":"https:\/\/mailitics.com\/index.php\/wp-json\/wp\/v2\/posts\/586"}],"collection":[{"href":"https:\/\/mailitics.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/mailitics.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/mailitics.com\/index.php\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/mailitics.com\/index.php\/wp-json\/wp\/v2\/comments?post=586"}],"version-history":[{"count":0,"href":"https:\/\/mailitics.com\/index.php\/wp-json\/wp\/v2\/posts\/586\/revisions"}],"wp:attachment":[{"href":"https:\/\/mailitics.com\/index.php\/wp-json\/wp\/v2\/media?parent=586"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mailitics.com\/index.php\/wp-json\/wp\/v2\/categories?post=586"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mailitics.com\/index.php\/wp-json\/wp\/v2\/tags?post=586"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}