Social previews that look like the repo, not the author

Sharing a repository link on Twitter or another social platform used to surface a generic card: the author’s avatar, the repo’s plaintext title, and little else. GitHub has long supported custom repository images, but most project owners never upload one. To improve the default experience for every repository, GitHub built a service that generates Open Graph images on the fly.

Screenshot of an old Twitter preview for GitHub repo links

The generated cards include the repository name, description, and more contextual details—and similar cards are now produced for issues, pull requests, and commits, with other resource types planned. The metadata is defined through standard Open Graph tags, including og:title and og:description, which crawlers from platforms like Twitter read when unfurling a link. The image itself is generated server-side by a Node.js application that fetches data from the GitHub GraphQL API, builds HTML from a template, and renders it with Puppeteer.

Screenshot of new Twitter preview card for NASA

From GraphQL to pixels

Routes in the image service mirror GitHub.com URL patterns:

// https://github.com/rails/rails/pull/41080
router.get("/:owner/:repo/pull/:number", generateImageMiddleware(Pull));

// https://github.com/rails/rails/issues/41078
router.get("/:owner/:repo/issues/:number", generateImageMiddleware(Issue));

// https://github.com/rails/rails/commit/2afc9059c9eb509f47d94250be0a917059afa1ae
router.get("/:owner/:repo/commit/:oid", generateImageMiddleware(Commit));

// https://github.com/rails/rails/pull/41080/commits/2afc9059c9eb509f47d94250be0a917059afa1ae
router.get("/:owner/:repo/pull/:number/commits/:oid", generateImageMiddleware(Commit));

// https://github.com/rails/rails/*
router.get("/:owner/:repo*", generateImageMiddleware(Repository));

When a request matches a route, the service queries the GraphQL API using the route parameters and generates an image from the returned data:

async function generateImage(template, templateData) {
 // Render some HTML from the relevant template
 const html = compileTemplate(template, templateData);
 
 // Create a new page
 const page = await browser.newPage();
 
 // Set the content to our rendered HTML
 await page.setContent(html, { waitUntil: "networkIdle0" });
 
 const screenshotBuffer = await page.screenshot({
   fullPage: false,
   type: "png",
 });
 
 await page.close();
 
 return screenshotBuffer;
}

The approach isn’t unique—projects like vercel/og-image use similar techniques—but building their own service gave GitHub the control needed to customize image output for any resource type.

Two performance fixes that mattered

Launching a full Chrome instance for every image is inherently slow, but the service initially suffered from avoidable delays. Profiling with Chromium traces revealed two significant bottlenecks.

Dropping networkidle0

The initial implementation passed waitUntil: networkidle0 to page.setContent(), which waits until there are no more than zero network connections for at least 500 ms. Trace analysis—done with the help of @MarshallOfSound—showed a ~2-second idle block even though all page resources were decoded and rendered by ~115 ms.

The fix was to stop relying on Puppeteer’s network heuristic. The service switched to waitUntil: domcontentloaded to ensure the HTML finished parsing, then used a custom function passed to page.evaluate that runs in the page context and waits for image load events to resolve:

   // Set the content to our rendered HTML
   await page.setContent(html, { waitUntil: "domcontentloaded" });
 
   // Wait until all images and fonts have loaded
   await page.evaluate(async () => {
     const selectors = Array.from(document.querySelectorAll("img"));
     await Promise.all([
       document.fonts.ready,
       ...selectors.map((img) => {
         // Image has already finished loading, let’s see if it worked
         if (img.complete) {
           // Image loaded and has presence
           if (img.naturalHeight !== 0) return;
           // Image failed, so it has no height
           throw new Error("Image failed to load");
         }
         // Image hasn’t loaded yet, added an event listener to know when it does
         return new Promise((resolve, reject) => {
           img.addEventListener("load", resolve);
           img.addEventListener("error", reject);
         });
       }),
     ]);
   });

That approach reduced image generation latency from roughly 2.25 seconds to ~600 ms.

The 512 MB threshold

Deployments on GitHub’s internal Kubernetes infrastructure default to a 512 MB memory limit. During scaling, the team raised that limit by just 1 MB—and saw an unexpected ~500 ms drop in image generation time. The cause: Chromium treats devices with less than 512 MB of memory as low-spec and runs some processes sequentially for reliability. Crossing that threshold enabled parallel execution. Anyone running a similar service should check whether their memory limit sits at or below this magic number.

Current state

Image generation now averages 280 ms per image. The service produces roughly two million unique images per day, and about 40% of requests are served from cache. Further gains would be possible—for instance, outputting JPEG instead of PNG—but the current defaults deliver the colorful social feeds the project set out to create.