example Trello board

Trello is a collaborative, feature-packed tool for project management. Its opinionated design makes is usable across all different workflows.

But we’re here to examine the tech behind what makes Trello so useful, namely real-time syncing and more generally the hidden features that make Trello “just work” (see copy/pasting).

prior art

NOTE: I read the following document after writing a good portion of the post your are reading, so a lot of my “finds” were outlined in more detail there. 🤷🏻‍♂️

Fog Creek, the original developers of Trello, detailed the tech stack behind the service in a 2012 blog post (you should read that).

Some takeaways. They used CoffeeScript, Backbone.js, Mustache. SocketIO is used for real time updates after XHR requests are used to fetch initial data on page load. For browsers without websocket support, they poll. Polling was more scalable for them when they hit high server load on launch.

On the server they use NodeJS, MongoDB with Mongoose, HTTP stuff with Express/Connect, Cluster to run multiple instances of NodeJS and support zero-downtime restarts. Mongo lets them keep their data in a denormalized form with great performance. Redis is used to share non-persistent information between processes and store model updates sent via websockets so polling clients can retrieve them. HAProxy load balances servers.

real-time updates

Trello uses a mix of HTTP requests and websockets to deliver updates to the browser. Upon initialization, most data is fetched through XHR requests and websockets are only initialized.

The following shows the subscription process for Trello’s websockets on page load. This occurs for the modelType’s Member, Board, Organization.

// request
{
    type: "subscribe",
    modelType: "Organization",
    idModel: "eae6ba2b536628bc89a0b802",
    invitationTokens: [],
    reqid: 3,
    tags: ["allActions", "updates"],
    type: "subscribe"
}
// response
{
    reqid: 3,
    result: 194
}

The backend server response with the header WebSocket-Server: uWebSockets, which leads me to uWebsockets. So although Trello used to use SocketIO, this is evidently no longer the case.

Upon changing boards, there isn’t an “unsubscribe” event, so I figure a client can only subscribe to one of the types mentioned above. On this board change, new subscriptions are made for Board and Organization models.

Trello uses what I’d consider “fat” api responses when loading the board. For example the plugins response (power ups), clocks in at a hefty 42.8KB and on a board with ~150 active cards, I get ~10KB-45KB (it varies based on the query parameters provided). I don’t have what I’d consider large Trello boards, but it seems like they do not paginate their responses.

The use of a large number of query parameters lets Trello customize the responses of their three basic api endpoints (Board, Member, Organization) and drastically change the response sizes (e.g. 10KB-45KB).

Here is an example URL:

https://trello.com/1/Boards/36dcde25?lists=open&list_fields=name%2Cclosed%2CidBoard%2Cpos%2Csubscribed%2Climits&cards=visible&card_attachments=cover&card_stickers=true&card_fields=badges%2Cclosed%2CdateLastActivity%2Cdesc%2CdescData%2Cdue%2CdueComplete%2CidAttachmentCover%2CidList%2CidBoard%2CidMembers%2CidShort%2CidLabels%2Climits%2Cname%2Cpos%2CshortUrl%2CshortLink%2Csubscribed%2Curl%2Clabels&card_checklists=none&members=all&member_fields=fullName%2Cinitials%2CidEnterprise%2CmemberType%2Cusername%2CavatarHash%2Cbio%2CbioData%2Cconfirmed%2Cproducts%2Curl%2Cstatus&membersInvited=all&membersInvited_fields=fullName%2Cinitials%2CidEnterprise%2CmemberType%2Cusername%2CavatarHash%2Cbio%2CbioData%2Cconfirmed%2Cproducts%2Curl&memberships_orgMemberType=true&checklists=none&organization=true&organization_fields=name%2CdisplayName%2Cdesc%2CdescData%2Curl%2Cwebsite%2Cprefs%2Cmemberships%2ClogoHash%2Cproducts%2Climits&organization_tags=true&myPrefs=true&fields=name%2Cclosed%2CdateLastActivity%2CdateLastView%2CdatePluginDisable%2CidOrganization%2Cprefs%2CshortLink%2CshortUrl%2Curl%2CcreationMethod%2Cdesc%2CdescData%2CidTags%2Cinvitations%2Cinvited%2ClabelNames%2Climits%2Cmemberships%2CpowerUps%2Csubscribed

URL decoded we get:

lists=open&
list_fields=name,closed,idBoard,pos,subscribed,limits&
cards=visible&
card_attachments=cover&
card_stickers=true&
card_fields=badges,closed,dateLastActivity,desc,descData,due,dueComplete,idAttachmentCover,idList,idBoard,idMembers,idShort,idLabels,limits,name,pos,shortUrl,shortLink,subscribed,url,labels&
card_checklists=none&
members=all&
member_fields=fullName,initials,idEnterprise,memberType,username,avatarHash,bio,bioData,confirmed,products,url,status&
membersInvited=all&
membersInvited_fields=fullName,initials,idEnterprise,memberType,username,avatarHash,bio,bioData,confirmed,products,url&
memberships_orgMemberType=true&
checklists=none&
organization=true&
organization_fields=name,displayName,desc,descData,url,website,prefs,memberships,logoHash,products,limits&
organization_tags=true&
myPrefs=true&
fields=name,closed,dateLastActivity,dateLastView,datePluginDisable,idOrganization,prefs,shortLink,shortUrl,url,creationMethod,desc,descData,idTags,invitations,invited,labelNames,limits,memberships,powerUps,subscribed

typing status

When another user starts typing a comment, the following message is sent to websocket users. As the user continues to type, more of these will periodically be sent out. A user is considered to have stopped typing after a timeout of receiving no messages.

When a user starts typing, their client makes an HTTP call to:

https://trello.com/1/members/<CARD_ID>/editing

Which triggers the following JSON payload to be sent out to all subscribed clients.

{
  "notify": {
    "event": "updateModels",
    "typeName": "Member",
    "deltas": [
      {
        "id": "b32a43191d176cd672e3d5bb",
        "initials": "jdoe",
        "username": "johndoe",
        "avatarHash": "463a39bf9ca8b684260eedb4",
        "avatarUrl": "https://trello-avatars.s3.amazonaws.com/463a39bf9ca8b684260eedb4",
        "fullName": "John Doe",
        "editing": {
          "idBoard": "1c230aa31e2bc7a2df0fb434",
          "timestamp": 1538022188911,
          "action": "commenting",
          "idCard": "cc321bd36b308c624ad181ab"
        }
      }
    ],
    "tags": [
      "updates"
    ],
    "idBoard": "1c230aa31e2bc7a2df0fb434"
  },
  "idModelChannel": "1c230aa31e2bc7a2df0fb434",
  "ixLastUpdateChannel": 322
}

moving cards

Just like typing statues, when a user moves a card an HTTP request is made to the following URL

https://trello.com/1/cards/<CARD_ID>

and a JSON payload is sent to all clients from the server announcing this update.

{
  "notify": {
    "event": "updateModels",
    "typeName": "Action",
    "deltas": [
      {
        "id": "b7539bb5c6f3e7cf7c3b365b",
        "idMemberCreator": "d8f4562acdf061e80b81e843",
        "data": {
          "listAfter": {
            "name": "Backlog",
            "id": "757778c4e8e8debc19343fd3"
          },
          "listBefore": {
            "name": "Doing",
            "id": "c15cb628b57c7bcc0d1c9f01"
          },
          "board": {
            "shortLink": "5e011889",
            "name": "Some Board",
            "id": "eea24226f66027151f22c8d8"
          },
          "card": {
            "shortLink": "2c0c671d",
            "idShort": 1186,
            "name": "Some Feature",
            "id": "fb1c4a3aee7e7338fd8c28b0",
            "idList": "757778c4e8e8debc19343fd3"
          },
          "old": {
            "idList": "c15cb628b57c7bcc0d1c9f01"
          }
        },
        "type": "updateCard",
        "date": "2018-09-27T06:23:10.347Z",
        "limits": {},
        "display": {
          "translationKey": "action_move_card_from_list_to_list",
          "entities": {
            "card": {
              "type": "card",
              "idList": "757778c4e8e8debc19343fd3",
              "id": "fb1c4a3aee7e7338fd8c28b0",
              "shortLink": "2c0c671d",
              "text": "Some Feature"
            },
            "listBefore": {
              "type": "list",
              "id": "c15cb628b57c7bcc0d1c9f01",
              "text": "Doing"
            },
            "listAfter": {
              "type": "list",
              "id": "757778c4e8e8debc19343fd3",
              "text": "Backlog"
            },
            "memberCreator": {
              "type": "member",
              "id": "d8f4562acdf061e80b81e843",
              "username": "johndoe",
              "text": "John Doe"
            }
          }
        },
        "entities": [
          {
            "type": "member",
            "id": "d8f4562acdf061e80b81e843",
            "username": "johndoe",
            "text": "John Doe"
          },
          {
            "type": "text",
            "text": "moved"
          },
          {
            "type": "card",
            "idList": "757778c4e8e8debc19343fd3",
            "id": "fb1c4a3aee7e7338fd8c28b0",
            "shortLink": "2c0c671d",
            "text": "Some Feature"
          },
          {
            "type": "text",
            "text": "from"
          },
          {
            "type": "list",
            "id": "c15cb628b57c7bcc0d1c9f01",
            "text": "Doing"
          },
          {
            "type": "text",
            "text": "to"
          },
          {
            "type": "list",
            "id": "757778c4e8e8debc19343fd3",
            "text": "Backlog"
          }
        ],
        "memberCreator": {
          "id": "d8f4562acdf061e80b81e843",
          "avatarHash": "463a39bf9ca8b684260eedb4",
          "avatarUrl": "https://trello-avatars.s3.amazonaws.com/463a39bf9ca8b684260eedb4",
          "fullName": "John Doe",
          "initials": "JD",
          "username": "johndoe"
        }
      }
    ],
    "tags": [
      "allActions",
      "clientActions"
    ],
    "idAction": "l-l-l-legacy"
  },
  "idModelChannel": "eea24226f66027151f22c8d8",
  "ixLastUpdateChannel": 9690
}
{
  "notify": {
    "event": "updateModels",
    "typeName": "Card",
    "deltas": [
      {
        "id": "fb1c4a3aee7e7338fd8c28b0",
        "idList": "757778c4e8e8debc19343fd3",
        "idBoard": "eea24226f66027151f22c8d8",
        "dateLastActivity": "2018-09-27T04:38:10.345Z",
        "closed": false
      }
    ],
    "tags": [
      "updates"
    ],
    "idBoard": "eea24226f66027151f22c8d8"
  },
  "idModelChannel": "eea24226f66027151f22c8d8",
  "ixLastUpdateChannel": 9691
}

bundle serving

Many services keep their landing page separate from their application. Stripe for example uses stripe.com for marketing and dashboard.stripe.com for their application. Trello does it all in one. If you navigate to Trello without an authentication cookie, Trello will serve the marketing/landing page, whereas if you are authenticated, you get the expected application.

Trello does do some lazy loading via Webpack for locales and for the “GammaApp” (react) and “ClassicApp” (jquery), depending on the route.

authentication

Trello uses cookie authentication. Nothing remarkable here. They also use a token set via a cookie when creating a websocket connection.

HTML oddities

There is one hidden text box used in the application for all text boxes in the application (comments, card titles/descriptions), excluding the search box.

react with an old application

Trello uses React in its application, but you may note that React wasn’t open sourced until 2013 (it did exist privately at Facebook in 2011), so did they rewrite their application in React?. No, that would be a horrible idea (see Joel’s blog post). This is where single-spa comes in. It’s billed as “microservices” for the web, but basically it makes it easier to integrate an existing application with a new framework like React.

We see this in the HTML. There is a div with id="root", where React mounts, but is hidden because it isn’t used on the main Trello board. A sibling div with id="classic-body" actually contains all the board html and is used for most of the interactions.

Something odd I found is the @atlassian/trello-canonical-components package has react, styled-components in folders named CanonicalBoard, CanonicalCard, CanonicalDetail. These components do look like more than just styled nodes, so I’m not sure what’s going on here.

After looking in the code more, it seems that React is only used for a few views: “superhome”, “power_ups”, “shortcuts”, and “error”. All of these have some level of A/B testing associated with them and some are outright disabled.

copy/paste (the OG way)

If you need to interact with a user’s clipboard in JS, you probably know of the clipboard API. If you want to copy some text into a users clipboard, you select some text and use document.execCommand('copy'). However, if you want to handle a paste, there’s more issues with security and browser compatibility.

Fortunately, the Trello developer of this feature wrote a StackOverflow answer explaining the whole feature in detail, which I encourage you to look at. The gist is to use a <textarea> positioned off screen, and change focus when a user hits CMD or CTRL, in preparation of a CMD+V/CTRL-V keystroke. Then you copy the pasted information from the text area.

date/time picker

Based on the CSS classes, Trello appears to use a heavily modified version of a library called Pikaday. I admire the simplicity of this widget. I’d argue it’s the best of its kind. Allowing text entry of times is so much nicer than flipping through normal time pickers.

Something interesting I’ve experienced is that if you enter an invalid date or time via the text boxes and hit enter, Trello will just use the last valid date or time and continue without error.

Trello's beautiful date picker

drag and drop

Drag and drop appears surprisingly simple for Trello. With their use of JQuery, all they do is use the JQuery Sortable package. This package doesn’t use the the drag and drop API so they are able to style and modify the dragged element.

styling

Nothing remarkable here. Trello uses plain Less. No packages here folks besides normalize.css.

server side render

Nothing is rendered on the server side. Trello loads the JS bundle and initializes the app client side.

typescript

It seems that this usage is limited to some experiment code for A/B testing, metrics, some localization, and some minor features like “tips” and “emojis”.

misc findings

The biggest help here is that Trello has sourcemaps enabled in production, so we can look under the hood and see the raw, un-uglified code. The “old” Trello app written in Coffeescript is huge, so I would suggest poking around.

  • Tools from network requests
  • JS libraries
    • JQuery
  • node_modules/ — there’s a lot of packages here and I’m not sure how much use it is to list them all but here’s a fraction of them.
    • @atlassian
    • @atlassianox/anlaytics-web-client/dist
    • @ndhoule
    • @segment
    • analytics-events/lib
    • asap
    • babel-runtime
    • bind-all/lib
    • bluebird/js/browser
    • blueimp-md5/js
    • classnames
    • component-bind
    • component-clone
    • component-cookie
    • component-each
    • warning
    • visiblityjs
    • value-equal
    • uuid
    • utf8-encode
    • url-polyfill
    • url-parse
    • unsplash-js/dist
    • underscore
    • uncontrollable
    • type-component
    • trim
    • to-no-case
    • to-function
    • timers-browserify
    • teacup/lib
    • symbol-observables/es
    • stylis-rule-sheet
    • stylis
    • styled-components
    • style-loader/lib
    • spark-md5
    • slug-component
    • single-spa/lib
    • setimmediate
    • segmentio-facade/lib
    • script-onload
    • whatwg-fetch
    • xtend
    • yields-store
    • yields-unserialize
  • react with styled-components
    • components/
      • CanoninicalBoard
      • CanoninicalCard
      • CanoninicalDetail
    • not much else besides lodash for JS
    • typescript in some places
  • no content security headers. tsk tsk.