Skip to content
Go back

Rails is the Brain, Go is the Photographer

Rails is the Brain, Go is the Photographer

Rails is the Brain, Go is the Photographer

I’ve been a developer for over 20 years. I love Ruby on Rails. It is, without a doubt, the fastest way to go from “idea” to “shipped product.”

But for my latest project, AyatFlow, I hit a wall where Rails wasn’t the right tool for the job.

AyatFlow generates high-quality typographic art for Quranic verses. I wanted the design flexibility of CSS (custom fonts, flexbox, absolute positioning), but I needed the output to be a high-res PNG that users could share on WhatsApp or Instagram.

The standard way to do this is “Headless Chrome”—spinning up a browser instance, rendering the HTML, and taking a screenshot.

If you try to orchestrate a headless browser inside a standard Rails request cycle (using gems like Grover or Puppeteer), you are inviting chaos. Browser processes are heavy, memory-hungry, and prone to “zombie” processes. If a render hangs, your Rails worker hangs. In a containerized environment (like Docker on a VPS), one heavy screenshot job could starve your entire web server.

I didn’t want my homepage to lag just because someone was generating a 4K image.

So, I split the stack. I kept Rails as the “Brain” and hired Go as the “Photographer.”

The Architecture: A “Sidecar” Service

I didn’t go full “microservices architecture” with complex service discovery. I just built a “Sidecar.”

  1. The Brain (Rails 8): Handles the user interface, search logic, database, and providing the “Template” (the HTML/CSS to be rendered).
  2. The Photographer (Go): A tiny service using chromedp that manages a headless Chrome instance. It accepts HTML and returns a screenshot.

If the Go service is down or overloaded, the worst case is a failed image generation, not a degraded web experience.

Why This Doesn’t Belong in Rails

I chose Go for this orchestration for two specific reasons:

  1. Context Management: The chromedp package relies heavily on Go’s context. This makes it incredibly easy to set hard timeouts. If a render takes longer than 2 seconds? Kill it. No zombie Chrome tabs left eating RAM.
  2. Concurrency: Go handles the concurrent browser contexts much better than a single-threaded Ruby process. I can keep a “warm” browser instance running and just open new tabs (contexts) for each request, which is significantly faster than booting Chrome from scratch every time.

The Glue Code

The communication is boringly simple: HTTP.

On the Rails side, I construct the HTML payload (often just a simple render_to_string of a View component) and ship it to Go:

# app/services/image_generator.rb
class ImageGenerator
  def self.call(html_content, width, height)
    # The Go service runs on port 8080 within the same private network
    response = HTTP.timeout(5).post("http://image-service:8080/screenshot", json: {
      html: html_content,
      width: width,
      height: height
    })

    return nil unless response.status.success?
    response.body.to_s # Returns the PNG bytes
  end
end

On the Go side, I use echo for the server and chromedp for the heavy lifting:

// main.go

import (
	// other imports

	"github.com/chromedp/cdproto/page"
	"github.com/chromedp/chromedp"
)

type Payload struct {
	Html   string `json:"html"`
	Width  int64  `json:"width"`
	Height int64  `json:"height"`
}

func handleScreenshot(c echo.Context) error {
    var req Payload
    if err := c.Bind(&req); err != nil {
        return err
    }

    // Create a context with a timeout (Safety first!)
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    // Initialize Chrome (or attach to existing allocator)
    ctx, cancel = chromedp.NewContext(ctx)
    defer cancel()

    var buf []byte
    
    // The Magic: Render HTML, Wait for it to be ready, Snap Screenshot
    err := chromedp.Run(ctx,
        chromedp.Navigate("about:blank"),
        chromedp.EmulateViewport(req.Width, req.Height),
        chromedp.ActionFunc(func(ctx context.Context) error {
            // Inject the user's HTML directly into the page
            frameTree, _ := page.GetFrameTree().Do(ctx)
            return page.SetDocumentContent(frameTree.Frame.ID, req.Html).Do(ctx)
        }),
        chromedp.FullScreenshot(&buf, 90),
    )

    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to capture"})
    }

    return c.Blob(http.StatusOK, "image/png", buf)
}

Deploying with Kamal

The best part of this setup is that deployment didn’t become a nightmare. I use Kamal.

I simply deploy the Go container alongside the Rails container on the same server. They talk over the private Docker network. If the Chrome process crashes (as browsers tend to do), the container restarts instantly without affecting the main Rails app.

The Pragmatic Choice

It is tempting to try and do everything in your primary language to keep the codebase “pure.” But sometimes, the best architectural decision is to admit that your framework shouldn’t be doing heavy lifting.

Rails manages the business logic. Go manages the browser. They work together perfectly. Frameworks are for coordination. Heavy lifting belongs where it can fail safely.

If this saved you time or helped you reason about a trade-off, feel free to reply on Twitter or email me .


Share this post on:

Previous Post
Rails Removed Email Obfuscation. Here's How I Brought It Back with Stimulus
Next Post
What A Philosophy of Software Design Taught Me About Writing Better Software