Omni : Speak anywhere. Act everywhere.

Cross-Client AI: How One Conversation Spans Mobile, Web, Desktop, and Smart Glasses with Gemini

GitHub: https://github.com/omanandswami2005/omni-agent-hub-with-gemini-live
Devpost Blog: https://devpost.com/software/omni-speak-anywhere-act-everywhere

The technical story behind Omni’s most unique feature — real-time device orchestration powered by Google Gemini Live API

2. The Inspiration

The idea for Omni was born from pure frustration. I was walking down the street, speaking into my phone’s AI assistant, asking it to write a complex Python script. It generated the code perfectly, but I was on a tiny screen. I couldn’t test it, copy it easily, or run it. I had to email it to myself and open it on my desktop later.

Why should a conversation be trapped on the device that started it? Why couldn’t I tell my phone to open the script on my laptop? That singular question led me to build Omni for the #GeminiLiveAgentChallenge.

3. Session-First Architecture

Traditional AI assistants use a “Client-First” architecture. You log into an app, and that app starts an isolated conversation with the cloud. If you open the same app on your laptop, it’s a completely different conversation.

Omni uses a “Session-First” architecture. The AI session exists independently in the cloud. Devices don’t own the conversation; they subscribe to it. When you speak into your phone, the audio is processed by the Gemini Live API. When the AI responds, it doesn’t just reply to the phone. It broadcasts its response, both audio and structured UI data, to every device currently subscribed to your session.

4. Event Bus Deep Dive

To make this work with near-zero latency, I built a custom Fan-out Pub/Sub Event Bus in backend/app/services/event_bus.py.

When the live WebSocket pipeline (ws_live.py) receives a structured response from Gemini, it publishes it:

class EventBus:
    """Per-user fan-out event distribution with bounded queues."""

    def __init__(self, queue_maxsize: int = _DEFAULT_QUEUE_MAXSIZE) -> None:
        # { user_id: set[asyncio.Queue] }
        self._subscribers: dict[str, set[asyncio.Queue[str]]] = {}
        self._queue_maxsize = queue_maxsize

Every connected client (Web Dashboard, Desktop App, Mobile PWA) subscribes to this bus. The challenge was deduplication. If your phone sends a command, and the AI renders a chart, you want the chart on your desktop monitor, but you might not want the phone to waste resources rendering the exact same heavy chart if it’s already playing the audio. Omni uses an origin_conn ID injected into the event payload so clients can filter or adapt their rendering logic based on whether they initiated the action.

5. Cross-Device Orchestration (Layer 3)

The magic happens in the Device Controller agent (backend/app/agents/cross_client_agent.py). This agent acts as a proxy for physical hardware actions.

Let’s walk through a real scenario: “Take a screenshot on my desktop.”

  1. User Speaks: You speak the command into your phone. The audio streams via WebSocket to the Omni Hub.
  2. Classification: The Root Router classifies this as a hardware command and uses transfer_to_agent to hand off to the Device Controller.
  3. Command Forwarding: The Device Controller looks up the send_to_desktop tool in its registry. It fires the command over the WebSocket to the connected Desktop Client.
  4. Local Execution: The PyQt6 Python Desktop Client receives the command and triggers PyAutoGUI/mss to capture the screen.
  5. Data Return: The screenshot is encoded and sent back up the WebSocket to the Hub.
  6. AI Analysis: The Hub injects the image into the Gemini Live session. Gemini analyzes the screen.
  7. Response: Gemini responds via audio back to your phone, while simultaneously pushing a GenUI summary of the screen to the Web Dashboard.
CROSS_CLIENT_INSTRUCTION = (
    "You are the Device Controller agent. You route actions to the user's REAL connected devices "
    "(desktop tray app, Chrome extension, web dashboard).nn"
    "## Your Toolsn"
    "- send_to_desktop: Send an action to the user's desktop tray app (e.g. open_app, type_text, capture_screen).n"
)

6. Client Deep Dives

Building the clients was an exercise in distinct technologies communicating over a shared protocol.

  • Web Dashboard: Built with React 19 and Vite. It connects via standard WebSockets. It handles the heavy lifting of rendering GenUI components (Charts, Code Blocks) sent from the backend.
  • Desktop Client: A Python application using PyQt6 for a system tray GUI and qasync to integrate asyncio WebSockets. It acts as the physical actuator, executing shell commands or capturing the screen.
  • Chrome Extension: A Manifest V3 extension. It uses an offscreen document to handle audio capture and playback since service workers lack access to Web Audio APIs. It reads the DOM and injects AI context.
  • Smart Glasses: ESP32 hardware using a custom UDP bridge to convert UDP packets into standard WebSocket frames for the Hub.
  • CLI: A fast, text-only interface for developers using Typer.
  • Mobile PWA: Built to handle the microphone and audio playback seamlessly on the go.

7. Mic Floor Management

When multiple devices are connected to a single audio stream, chaos ensues if two devices try to transmit at once. I implemented a Mic Floor State Machine in backend/app/api/ws_live.py.

The system has three states:

  • IDLE: No one is speaking.
  • STREAMING: A device has requested and been granted the mic.
  • LISTEN_ONLY: A device has been temporarily muted because another device holds the floor.

When your phone starts streaming audio, it requests the floor. The backend grants it and broadcasts a LISTEN_ONLY event to your Desktop and Web Dashboard. They immediately disable local microphone capture until the phone releases the floor or an auto-timeout occurs.

8. Memory Layer

Context isn’t just about the current session; it’s about history. Omni uses a dedicated Memory Service interacting with Firestore. When a session ends, or when explicitly commanded, the AI extracts key facts (“User prefers Python over Java”, “User works in finance”) and stores them in the Knowledge Bank. When you start a new session on a different device tomorrow, those facts are injected into the Root Router’s context prompt, ensuring continuity across time and hardware.

9. Tech Stack Summary

  • Backend: Python 3.12+, FastAPI, Google Cloud Run, WebSockets.
  • AI Core: Google Gemini Live API, Google ADK.
  • Database: Google Cloud Firestore.
  • Auth: Firebase Authentication.
  • Clients: React 19 (Web), PyQt6 (Desktop), Typer (CLI), Manifest V3 (Chrome).

10. Try It

Experience the seamless cross-client orchestration yourself:

11. What’s Next for Cross-Client

The immediate next step is expanding the capability registry. I want the Desktop Client to automatically expose installed applications (like VS Code or Docker) as T3 tools to the AI without manual configuration. Furthermore, I’m working on deeper integration with the Chrome Extension to allow the AI to actively scroll and click on elements based on voice commands from a completely different device.

This blog post was created for the purposes of entering the Gemini Live Agent Challenge Hackathon hosted on Devpost. #GeminiLiveAgentChallenge

Deep Dive into the Technical Challenges

Building a truly synchronized multi-client system surfaced unique synchronization issues. For example, if a user speaks into their mobile phone, the phone captures the PCM audio and streams it to the FastAPI backend. The backend forwards this to Vertex AI. Vertex AI processes it and returns both an audio response and structured JSON data representing the UI state.

The backend must stream the audio back to the mobile phone for playback, while simultaneously broadcasting the structured JSON UI data to all connected clients (like the desktop web dashboard) via the Event Bus. If the Event Bus is too slow, the user hears the audio response before seeing the UI update. If the audio buffer is mismanaged, the speech stutters. Balancing the asyncio.gather tasks in ws_live.py was paramount to achieving the seamless “Omni” experience.

Furthermore, integrating the TaskArchitect required careful schema definitions. When a user asks a complex question like “Research top AI stocks, execute a python script to calculate their P/E ratio, and graph the results”, the Root Agent must recognize the complexity, avoid calling a simple tool, and hand off to the TaskArchitect. The TaskArchitect then generates a Directed Acyclic Graph (DAG) of tasks, executing them sequentially or in parallel, while publishing progress updates to the Event Bus so the React frontend can render a live progress bar.

This project demonstrated the immense power of the Google Gemini Live API when paired with a robust, event-driven, multi-modal backend.

Further Architectural Considerations

The design of the PluginRegistry was another significant hurdle. In a multi-agent system, providing every agent with every tool schema consumes massive amounts of the context window. By implementing a lazy-loading mechanism, the Root Router only sees a ToolSummary. When it decides a tool is necessary, it requests the full ToolSchema from the registry. This drastically reduces token usage and latency.

The Device Controller (Layer 3) handles the physical cross-device execution. When the desktop client connects to the WebSocket, it advertises its local capabilities (e.g., take_screenshot, open_application). The backend dynamically registers these as T3 proxy tools. If the user commands their phone to “Take a screenshot on my desktop”, the Root Router delegates to the Device Controller, which invokes the take_screenshot proxy tool. The backend forwards this command over the WebSocket to the PyQt6 desktop client, which executes the local OS command using PyAutoGUI or mss, captures the screen, and sends the image data back to the backend. The backend then injects this image into the Gemini Live session, allowing the AI to analyze the desktop screen entirely via a voice command originating from the phone. This is the true power of Omni.

Deep Dive into the Technical Challenges

Building a truly synchronized multi-client system surfaced unique synchronization issues. For example, if a user speaks into their mobile phone, the phone captures the PCM audio and streams it to the FastAPI backend. The backend forwards this to Vertex AI. Vertex AI processes it and returns both an audio response and structured JSON data representing the UI state.

The backend must stream the audio back to the mobile phone for playback, while simultaneously broadcasting the structured JSON UI data to all connected clients (like the desktop web dashboard) via the Event Bus. If the Event Bus is too slow, the user hears the audio response before seeing the UI update. If the audio buffer is mismanaged, the speech stutters. Balancing the asyncio.gather tasks in ws_live.py was paramount to achieving the seamless “Omni” experience.

Furthermore, integrating the TaskArchitect required careful schema definitions. When a user asks a complex question like “Research top AI stocks, execute a python script to calculate their P/E ratio, and graph the results”, the Root Agent must recognize the complexity, avoid calling a simple tool, and hand off to the TaskArchitect. The TaskArchitect then generates a Directed Acyclic Graph (DAG) of tasks, executing them sequentially or in parallel, while publishing progress updates to the Event Bus so the React frontend can render a live progress bar.

This project demonstrated the immense power of the Google Gemini Live API when paired with a robust, event-driven, multi-modal backend.

Further Architectural Considerations

The design of the PluginRegistry was another significant hurdle. In a multi-agent system, providing every agent with every tool schema consumes massive amounts of the context window. By implementing a lazy-loading mechanism, the Root Router only sees a ToolSummary. When it decides a tool is necessary, it requests the full ToolSchema from the registry. This drastically reduces token usage and latency.

The Device Controller (Layer 3) handles the physical cross-device execution. When the desktop client connects to the WebSocket, it advertises its local capabilities (e.g., take_screenshot, open_application). The backend dynamically registers these as T3 proxy tools. If the user commands their phone to “Take a screenshot on my desktop”, the Root Router delegates to the Device Controller, which invokes the take_screenshot proxy tool. The backend forwards this command over the WebSocket to the PyQt6 desktop client, which executes the local OS command using PyAutoGUI or mss, captures the screen, and sends the image data back to the backend. The backend then injects this image into the Gemini Live session, allowing the AI to analyze the desktop screen entirely via a voice command originating from the phone. This is the true power of Omni.

Why Does AI Keep Saying “It’s Not X, It’s Y”?

You say no human would reply to a forum thread about Tom Bombadil by writing and editing hundreds of words of text, complete with formatting, fancy punctuation, and two separate uses of the word

People have started compiling screenshots of a phrase that appears constantly in AI-generated text. “It’s not just a tool — it’s a paradigm shift.” “It’s not about efficiency; it’s about transformation.” The pattern is a structural artifact of how large language models are trained, aligned, and instructed, and understanding its origins reveals something significant about the current state of AI development.

The Pattern Has a Name

In classical rhetoric, this construction is called antithesis, or negative-positive parallelism. It works by first negating a familiar assumption, then replacing it with something supposedly more expansive or profound.

When used by human writers, it’s a high-impact device deployed sparingly. Advertising copywriters use it to reframe value propositions. Political speechwriters use it to signal a turning point. The entire effect depends on restraint.

A language model has no concept of restraint, applying the structure indiscriminately whether describing a recipe, explaining a bug fix, or summarizing a business report. A rhetorical technique designed for occasional emphasis becomes a verbal tic repeated across every response.

Where the Pattern Comes From

The internet is disproportionately populated by text written to sell, persuade, or maximize engagement. Content marketing, SEO articles, LinkedIn thought leadership posts, and corporate press releases all rely heavily on contrast framing.

During pre-training, a language model absorbs the statistical distribution of human language across billions of documents. When text sounds authoritative or insightful, the probability of encountering an “It’s not X, it’s Y” sequence is high. The model encodes the structure as a valid marker of “expert communication,” with no capacity to recognize it as a marketing convention.

The overuse follows directly from training on human-generated web text. The models are mirroring the engagement-optimized habits of the internet, distilling the collective style of digital marketing into a predictable format.

How RLHF Locks It In

Pre-training loads the pattern into the model’s weights, and Reinforcement Learning from Human Feedback (RLHF) cements it through preference optimization.

In RLHF, human annotators compare pairs of model responses and rank them for quality. Their rankings train a Reward Model, which then automatically scores the LLM’s outputs at scale. Annotators are instructed to reward nuance, multi-faceted perspectives, and comprehensive insight.

The problem is that human annotators frequently conflate the appearance of nuance with actual depth. When evaluating two competing responses, a rater is statistically more likely to upvote a response using contrast framing. The full antithesis structure, “Photosynthesis is not merely a biological function — it is the foundational engine of life on Earth,” reads as more insightful than a flat declarative like “Photosynthesis converts light into energy,” even when it carries less information.

Over millions of RLHF iterations, the Reward Model learns a proxy: dialectical hedging correlates with high helpfulness scores. The LLM learns to inject contrast framing into responses because doing so inflates its reward, regardless of whether it adds meaning.

RLHF also trains models to be face-saving. Models are penalized for being blunt, dismissive, or overconfident. The “It’s not just X, it’s Y” structure is safe, validating the user’s existing assumption while adding a layer of elaboration that makes it extremely difficult for a rater to flag the response as unhelpful. Researchers studying this dynamic have described the result as AI conditioned for servility.

The Mathematics Behind the Structure

Recent research has shown that both RLHF and its successor, Direct Preference Optimization (DPO), can be interpreted as forms of contrastive learning. The model is trained to increase its alignment with “chosen” (human-preferred) responses and decrease its alignment with “rejected” ones.

In practice, rejected responses tend to be brief, flat, and declarative, while chosen responses tend to be verbose, expansive, and multi-faceted. The model learns to structurally oppose the negative sample within its own generation. Prefacing with a negation (“it’s not X”) distances the token trajectory from the rejected sample space, and stating the elevated alternative (“it’s Y”) pulls it toward the preferred one.

The “It’s not X, it’s Y” structure is the direct linguistic output of this mathematical optimization. The antithesis surface form is what the contrastive objective produces most reliably, given the composition of human preference data.

The Institutional Layer

Beyond training dynamics, there is a more direct cause. Many LLMs operate under system prompts that instruct them to be “profoundly helpful,” to “look for deeper meanings,” or to “avoid generic responses.” A model without genuine subjective understanding uses structural proxies for depth, reaching for the constructions its training data associates with high-level conceptual thinking.

Anthropic’s Constitutional AI framework explicitly instructs Claude to balance competing values and perspectives. Anthropic has even sourced public input to draft these principles through a process called Collective Constitutional AI. When a model is constitutionally required to weigh tradeoffs in every response, it gravitates toward concessive structures: “While X is true, we must also consider Y.” The constitutional mandate to avoid reductive thinking maps directly onto dialectical hedging.

The Downstream Effects

This pattern, at scale, is doing measurable damage to written communication.

A 2025 study tracked stylistic convergence in peer-reviewed academic publications following the release of ChatGPT. Using SciBERT embeddings to measure linguistic similarity across millions of papers, researchers found a significant increase in LLM-associated linguistic features in the scientific literature. The study noted a reduction in authorial variance and individual intellectual voice, while acknowledging that AI assistance helps non-native English speakers achieve syntactic fluency in high-tier publishing.

Separate research analyzing persuasive discourse found that LLM-generated arguments cluster tightly together in linguistic space, showing high similarity to each other and low similarity to human writing. LLMs exhibit lower lexical richness than humans and maintain unnaturally high scores for “anticipation” and “trust” even in adversarial contexts, drawing from a shared rhetorical playbook with contrast framing as one of its core moves.

When every paragraph builds to a reversal, readers stop experiencing the reversal as meaningful. The scaffolding becomes visible, and the rhetorical performance crowds out the substance.

Why Simple Fixes Don’t Work

Asking a model to “stop sounding like an AI” rarely works. The model typically switches to a different formula, often adopting over-casual language or forced contrarianism, while maintaining the underlying contrastive logic.

Negative instructions (“Do not say ‘it’s not X, it’s Y'”) are similarly ineffective. The model’s internal representation of high-quality text is structurally tied to contrast framing through DPO and RLHF. Blocking a specific phrase pushes the model to find a synonymous structure, so “While X is true, Y is paramount” ends up carrying the same rhetorical function under a different surface form.

Effective mitigation requires structural prohibition. Prompts that target the behavior at the level of linguistic category, prohibiting “thesis-antithesis patterns,” “dialectical hedging,” and “rhetorical equivocation,” are significantly more effective because they block the statistical pathway established during alignment training, rather than just banning a specific phrase. Instructing the model to define things affirmatively, and to treat contrastive structures as high-impact tools used sparingly, produces noticeably more direct prose.

The Deeper Problem

The “It’s not X, it’s Y” pattern is a symptom of an alignment strategy that has optimized for the appearance of nuance, using rhetorical elaboration as the proxy signal for quality. RLHF as currently practiced penalizes declarative confidence and rewards elaborate framing, because human evaluators operating under time pressure consistently mistake rhetorical complexity for intellectual depth.

The signal annotators send is “this sounds thorough,” and the model learns to optimize for exactly that signal. The output is text that reads as considered but delivers less information than a direct statement would.

Fixing this requires rethinking what signals human annotators are actually rewarding during preference tuning, and whether those signals reliably correspond to quality. Until that changes, AI-generated text will keep reaching for the same contrastive move, in every response, on every topic, at scale.

Research sourced from academic papers on RLHF, DPO, and LLM stylistics, including work from arXiv on contrastive learning in alignment and ACL Anthology findings on AI argumentative style.

You say no human would reply to a forum thread about Tom Bombadil by writing and editing hundreds of words of text, complete with formatting, fancy punctuation, and two separate uses of the word 'delve'. Unfortunately for both of us, you are wrong.

The Reasoning Behind AQC Class Design Choices

The Reasoning Behind AQC Class Design Choices

The Reasoning Behind AQC Class Design Choices

The Atomic Query Construction (AQC) design pattern is all about precision, clarity, and predictability. Over time, I’ve developed a consistent structure for AQC classes that may seem restrictive at first glance, but each restriction serves a practical purpose. Let’s break down why AQC classes are defined the way they are.

1. No Constructor: Avoiding Dependency Injection

In many design patterns, constructors are used to inject dependencies—services, repositories, or other objects. AQC classes intentionally avoid constructors. Why? Because the AQC philosophy assumes that the underlying service or query builder never changes internally.

Injecting a dependency through a constructor implies that the class can be swapped or configured differently for each instance. For AQC classes, this is unnecessary overhead. The logic is atomic, deterministic, and isolated. By not requiring a constructor, you eliminate boilerplate, reduce configuration errors, and keep the class focused purely on its query logic.

It also simplifies instantiation. You just create an instance whenever needed, without worrying about passing services or setup parameters that will never vary.

$getUsers = new GetUsers(); // simple and predictable
$result = $getUsers->handle(['status' => 'active']);

2. Non-Static Methods: Encouraging Instance-Based Clarity

Some developers might wonder: why not make handle() static? A static method would avoid instantiation and look simpler. But static methods carry hidden costs: they obscure state, make testing harder, and encourage global coupling.

Using non-static methods ensures each instance is independent, even if it doesn’t hold state now. It’s a design discipline. By always creating an object before calling handle(), the code communicates intent: “this is a self-contained, reusable atomic query unit.”

3. Single Public Method (handle) with params Array

The handle() method is the only public interface of an AQC class. This makes the API extremely predictable: no guessing which method to call or which method affects the query.

The params array standardizes input. Whether the query needs a filter, a sort order, or pagination, everything is passed through a single array. This avoids proliferating method signatures and keeps the class atomic.

$params = [
    'status' => 'active',
    'role' => 'admin',
    'sort' => 'created_at'
];

$result = (new GetUsers())->handle($params);

Private methods handle the internal steps, such as building joins, filtering conditions, or selecting columns. They are invisible externally, which keeps the class contract simple and ensures maintainers only focus on the handle method.

4. Why This Pattern Works

This design achieves three key goals:

  • Predictability: Every AQC class looks and behaves the same way.
  • Simplicity: No unnecessary constructors, no dependency injection, no multiple public methods.
  • Flexibility: While the class is simple externally, private methods allow complex query construction internally.

By following these rules, teams can read, use, and maintain AQC classes without worrying about hidden state, dependencies, or inconsistent public APIs.

Final Thoughts

The structure of AQC classes—no constructor, non-static methods, single handle() public method accepting a **params** array—is deliberate. It enforces atomicity, simplicity, and predictability, which are the core principles of the Atomic Query Construction design pattern. Each class becomes a self-contained unit that can be easily reused, tested, and maintained without overengineering or unnecessary complexity.

TgCore: Not a Library. A Telegram Infrastructure SDK.

Open source: TgCore

❌ Regular library:

send_message(chat_id, text, parse_mode, disable_web_page_preview, ...)

TgCore ✅

tg.calls.sendMessage()
  .chatId(id)
  .text(text)
  .execute()

TgCore is not about writing bots.
It’s about building infrastructure.

TGCore is not just a library. It’s an SDK architecture.

Snakes & Gophers — A Python Dev’s Guide to Thinking in Go

You’ve written Python for years. You can pip install your way out of anything. Your decorators are elegant, your list comprehensions are crisp, and you’ve finally gotten comfortable with asyncio. And then someone says — “we’re moving to Go.”

This isn’t a Go introduction. If you want that, head to Go by Example — it’s the best. I’m also not going to list conceptual differences between Python and Go — you’re one search away from that.

This guide is about rewiring your instincts. Python and Go look different on the surface, but they think differently underneath. And that’s where the real adjustment happens.

The Big One: Concurrency

This is the only section that needs real focus. Everything else is honestly just language flavour.

Python has the GIL and the event loop. You’ve fought with threading, embraced asyncio, maybe even used multiprocessing when things got desperate. Go throws all of that out.

In Go, you have goroutines and channels. That’s it. Goroutines are lightweight threads managed by the Go runtime — you can spin up thousands without breaking a sweat. Channels are how goroutines talk to each other.

Here’s what the same “fetch multiple URLs concurrently” pattern looks like:

Python:

import asyncio
import aiohttp

async def fetch(session, url):
    async with session.get(url) as resp:
        return await resp.text()

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        results = await asyncio.gather(*tasks)

Go:

func fetch(url string, ch chan<- string, wg *sync.WaitGroup) {
    defer wg.Done()
    resp, err := http.Get(url)
    if err != nil {
        ch <- ""
        return
    }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    ch <- string(body)
}

func main() {
    ch := make(chan string, len(urls))
    var wg sync.WaitGroup
    for _, url := range urls {
        wg.Add(1)
        go fetch(url, ch, &wg)
    }
    go func() { wg.Wait(); close(ch) }()
    for result := range ch {
        fmt.Println(result)
    }
}

More code? Yes. But there’s no event loop, no async/await colouring, and it’s truly parallel — not just concurrent. Every goroutine can run on a separate CPU core.

context.Context — The Thing Python Doesn’t Have

In Go, context.Context gets threaded through everything. Every HTTP handler gets one, every database call takes one, every goroutine should respect one. It carries deadlines, cancellation signals, and request-scoped values.

If you forget to cancel a context, you leak memory. This is not optional housekeeping — it’s a core part of how Go manages resources.

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // ALWAYS do this

result, err := db.QueryContext(ctx, "SELECT ...")

Think of it as Python’s asyncio.timeout() meets a request-scoped dependency injection container, except it’s a single, explicit argument.

The “No” List

This is the fun part. Here’s everything you’ll reach for and won’t find.

No try / except. This one is hard to get used to. Errors in Go are values, not exceptions. You’ll write val, err := hundreds of times a day. panic and recover exist for truly exceptional cases, but idiomatic Go treats errors as return values.

file, err := os.Open("config.json")
if err != nil {
    return fmt.Errorf("failed to open config: %w", err)
}
defer file.Close()

No None. Go uses zero values. An uninitialized string is "", an int is 0, a bool is false. If you need None/nil behaviour, use a pointer.

var name string  // "" not None
var count int    // 0 not None
var active *bool // nil — now you can distinguish "not set" from "false"

No default function arguments. def foo(x=10) doesn’t exist. The Go patterns are functional options or config structs.

// Instead of default args, use a config struct
type ServerConfig struct {
    Port    int
    Timeout time.Duration
}

func NewServer(cfg ServerConfig) *Server { ... }

No context manager. No with open(...) as f:. Use defer instead.

f, err := os.Open("file.txt")
if err != nil { return err }
defer f.Close() // runs when the function returns

No enums. Yes, really. You use iota with a const block, and it works, but it’s not the same. Check out this Ardan Labs article for patterns.

type Status int

const (
    Pending Status = iota // 0
    Active                // 1
    Closed                // 2
)

No decorators. No lambdas. Both become closures. functools.lru_cache for lazy loading? That’s sync.Once.

// Decorator pattern → closure
func withLogging(fn func()) func() {
    return func() {
        log.Println("start")
        fn()
        log.Println("end")
    }
}

// sync.Once for one-time initialization (like lru_cache for singletons)
var once sync.Once
var db *sql.DB

func GetDB() *sql.DB {
    once.Do(func() {
        db, _ = sql.Open("postgres", connStr)
    })
    return db
}

Pointers — Back to Basics

Python hides references from you. Go makes them explicit. You’ll need to think about value vs reference semantics again.

The biggest gotcha: Python’s slice syntax gives you copies. Go slices share backing memory.

# Python — this creates a new list
copy = original[:]
// Go — this shares the underlying array!
copy := original[:]
// Mutation to copy affects original. Use slices.Clone() for a real copy.

Quick mapping:

  • list → slice (or array for fixed-size)
  • dict → map
  • If you want a copy, make one explicitly

Modules & Imports

Go enforces a strict DAG for imports — no circular imports, ever. The compiler will stop you cold.

Coming from Python where circular imports are “just a warning” (or silently work sometimes), this feels restrictive. It’s actually a feature — it forces you into clean architecture from day one. Your module structure has to be well-organized.

Another difference: no local or dynamic imports. In Python, you can import x inside a function or use importlib based on runtime conditions. In Go, all imports must be at the top of the file, declared statically at compile time.

# Python: "it works... sometimes"
# a.py imports b.py, b.py imports a.py → 🤷
# def foo(): import heavy_module → runtime evaluation

# Go: compiler says no. Imports are static and top-level only.

OOP in Go (or: How I Learned to Stop Worrying and Love Composition)

You can achieve everything you did with Python classes — just differently.

No class keyword. You use structs with methods attached.

type User struct {
    Name  string
    Email string
}

func (u *User) Greet() string {
    return "Hi, I'm " + u.Name
}

// Usage:
func main() {
    u := &User{Name: "Alice", Email: "[email protected]"}
    fmt.Println(u.Greet()) // "Hi, I'm Alice"
}

No constructor. Write a factory function. Convention is NewThing().

func NewUser(name, email string) *User {
    return &User{Name: name, Email: email}
}

Encapsulation works through naming — lowercase is private (package-scoped), Capitalized is public. No underscores, no __dunder__, just capitalization.

Interfaces are implicitly satisfied. This is actually magical coming from Python’s ABC. You don’t write implements — if your struct has the right methods, it satisfies the interface automatically.

type Greeter interface {
    Greet() string
}

// User satisfies Greeter automatically — no "implements" needed

Polymorphism is interface-based, not inheritance-based. And there IS no inheritance. Composition is the default.

While Go does have a feature called “struct embedding” that mimics inheritance by promoting methods, it’s often better to just be explicit. Explicit fields prevent method collisions and make it instantly clear where data lives.

// Explicit composition (the clearer way)
type Admin struct {
    Account     User     // Admin explicitly "has a" User
    Permissions []string
}

func main() {
    a := &Admin{
        Account: User{Name: "Bob", Email: "[email protected]"},
        Permissions: []string{"ROOT"},
    }

    // Explicit calls mean no ambiguity if both Admin and User had a Greet() method
    fmt.Println(a.Account.Greet()) // "Hi, I'm Bob"
}

No overloading. Just write another function with a different name. It’s simpler and more explicit.

Tooling — You Already Have Everything

In Python, you assemble your toolchain: uv or pip or poetry for packages, black or ruff for formatting, pytest for tests, mypy for types. It’s a whole ecosystem.

In Go, you open a terminal:

  • go fmt — formats your code. It’s not optional. There is ONE style.
  • go test — runs your tests. No pytest, no unittest, no nose.
  • go build — compiles everything. The compiler IS your type checker.
  • go vet — catches suspicious constructs.
  • go mod — dependency management. That’s it.

No debates about formatters. No optional type checking. It’s all built in, and everyone uses the same thing. After the Python tooling fragmentation, this feels like a warm hug.

Writing Server Code

Once the mindset shift is done, here’s the practical stuff.

SQL: Go Leans Raw

Go leans towards raw SQL or SQL templates over ORMs. Having worked with SQLAlchemy and JinjaSQL, I personally lean towards the template approach — and there are solid Go libraries for SQL templating. Traditional connection pooling, transactions, and prepared statements all apply as normal.

Validation: Struct Tags, Not Pydantic

Using Pydantic for model validation? In Go, encoding/json with struct tags handles JSON decoding. The field naming ties into Go’s encapsulation — only Capitalized fields get exported/serialized.

type CreateUserRequest struct {
    Name  string `json:"name" validate:"required"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age"  validate:"gte=0,lte=150"`
}

func handler(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    json.NewDecoder(r.Body).Decode(&req)
}

Frameworks

  • Gin — mature and popular, the Django/Flask of Go
  • Echo, Fiber, Chi — lightweight options
  • Stdlib net/http is more capable than you’d think. Chi specifically follows stdlib patterns.

More Concurrency Primitives

Beyond goroutines and channels, you’ll want:

  • sync.WaitGroup — waiting for a bunch of goroutines to finish
  • sync.Mutex — locking shared memory
  • select statement — multiplexing across multiple channels (like asyncio.wait but for channels)

Background Workers

Conceptually, there’s not much difference from server code — you’re polling for work instead of HTTP requests.

If you’re coming from Python:

  • Celery → asynq, machinery
  • Temporal → Temporal (it’s cross-language, so you might already know it!)

I switched from Celery to Temporal for complex long-running workflows, and the Go SDK is excellent. But regardless of framework, the fundamentals don’t change — your message handlers still need to be idempotent. No framework saves you from that.

Closing Thoughts

Here’s the thing about Go: the “no” list is the feature list. No classes, no exceptions, no generics (well, they added them, but the culture still prefers simplicity), no decorators, no magic.

The learning curve isn’t about learning Go. It’s about unlearning Python habits — and realizing that less abstraction often means more clarity.

Start here:

Top 7 Featured DEV Posts of the Week

osmankahraman

Welcome to this week’s Top 7, where the DEV editorial team handpicks their favorite posts from the previous week.

Congrats to all the authors that made it onto the list 👏

@osmankahraman shares how they built _gitinder, a SwiftUI iOS app that lets developers discover GitHub repositories one swipe at a time. Along the way, they document real-world challenges including OAuth authentication choices, GitHub Search API limitations, and a clever local state queue that batches API calls instead of firing them all at once.

@grahamthedev introduces “Drift to Determinism” as a framework for understanding how AI coding tools should evolve—from unpredictable vibe coding toward reliable, verifiable outputs. The post argues that the real value in AI development isn’t the magic of generation, but the engineering work of making that generation trustworthy enough to ship.

@yabbal walks us through how they cracked open Chrome DevTools at 2:30 AM, mapped 40+ undocumented endpoints from an accounting app, and—with Claude Code as a pair programmer—shipped a zero-dependency TypeScript SDK and a full CLI with 14 commands in just four days. An honest account of what AI-assisted development looks like at its most scrappy and productive.

@georgekobaidze explores the thought experiment of skipping source code entirely and having AI generate binary output directly, then breaks down why it’s nearly impossible in practice. The post covers how such an approach would destroy determinism, make incremental changes unworkable, multiply token costs across every architecture target, and remove the only interface humans can actually read and maintain.

@dev-in-progress traces the evolution of structured output in LLM systems—from basic prompting, through JSON mode and function calling, all the way to strict json_schema enforcement with additionalProperties: false. The post lands on a clear mental model: tool calling is for triggering actions, while json_schema is for reliable structured data, and even then backend validation is still recommended.

@sleewoo makes the case that building your own web framework—even in an era when AI can generate one in minutes—is still deeply worth doing for the understanding it creates. The post traces a weekend-by-weekend evolution from runtime-validated routes to auto-generated OpenAPI specs, arguing that every annoyance solved reveals the next problem worth solving.

@sag1v raises a concern that doesn’t get enough attention: the public knowledge commons that trained today’s AI models is quietly shrinking as developers solve problems privately through AI assistants instead of posting publicly. The post explores the recursive risk this creates for future model training and sketches a rough idea for what an agent-native public knowledge platform might look like.

And that’s a wrap for this week’s Top 7 roundup! 🎬 We hope you enjoyed this eclectic mix of insights, stories, and tips from our talented authors. Keep coding, keep learning, and stay tuned to DEV for more captivating content and make sure you’re opted in to our Weekly Newsletter 📩 for all the best articles, discussions, and updates.

Build a Social Media Data Pipeline That Actually Scales

Your scraper runs fine for 100 profiles. At 10,000 it crashes. At 100,000 it’s a dumpster fire.

You’ve got timeouts. Duplicate records. Missing data. A Postgres database that takes 30 seconds to query. And a cron job that silently failed three days ago — nobody noticed until a client complained.

I’ve built data pipelines that process millions of social media records daily. The architecture isn’t complex. But it’s very different from “fetch in a loop and save to DB.”

Here’s the exact pipeline I use.

The Stack

  • Node.js – orchestration
  • SociaVault API – social media data source
  • BullMQ + Redis – job queue
  • PostgreSQL – storage
  • Cron – scheduling

The Problem With “Fetch and Save”

Here’s what most people start with:

// ❌ This doesn't scale
for (const username of usernames) {
  const profile = await fetchProfile(username);
  await db.insert('profiles', profile);
}

Why this breaks:

  1. One failure kills everything — if request #5,001 fails, you lose your place
  2. No parallelism — sequential = slow
  3. No retry logic — transient errors become permanent failures
  4. No deduplication — run it twice, get duplicate records
  5. No monitoring — you don’t know it’s broken until someone tells you

The Pipeline Architecture

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   Scheduler  │────▶│  Job Queue   │────▶│   Workers    │
│   (Cron)     │     │  (BullMQ)    │     │  (Fetch +    │
│              │     │              │     │  Transform)  │
└──────────────┘     └──────────────┘     └──────┬───────┘
                                                  │
                                           ┌──────▼───────┐
                                           │   Database    │
                                           │ (PostgreSQL)  │
                                           └──────────────┘

Four stages: Schedule → Queue → Process → Store

Step 1: The API Client

const axios = require('axios');

const API_BASE = 'https://api.sociavault.com/v1';
const API_KEY = process.env.SOCIAVAULT_API_KEY;

const api = axios.create({
  baseURL: API_BASE,
  headers: { 'x-api-key': API_KEY },
  timeout: 15000,
});

// Wrapper with automatic retry
async function fetchWithRetry(url, params = {}, retries = 3) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const { data } = await api.get(url, { params });
      return data;
    } catch (err) {
      if (attempt === retries) throw err;

      const isRetryable = err.response?.status === 429 || err.response?.status >= 500;
      if (!isRetryable) throw err;

      const delay = Math.pow(2, attempt) * 1000;
      console.log(`Retry ${attempt}/${retries} for ${url} in ${delay}ms`);
      await new Promise(r => setTimeout(r, delay));
    }
  }
}

Step 2: The Job Queue

BullMQ gives you retries, concurrency, rate limiting, and dead letter queues — for free.

const { Queue, Worker } = require('bullmq');
const Redis = require('ioredis');

const connection = new Redis(process.env.REDIS_URL);

// Create queues for each stage
const profileQueue = new Queue('fetch-profiles', { connection });
const postQueue = new Queue('fetch-posts', { connection });
const transformQueue = new Queue('transform-data', { connection });

// Add jobs in bulk
async function enqueueProfiles(usernames, platform) {
  const jobs = usernames.map(username => ({
    name: `profile:${platform}:${username}`,
    data: { username, platform },
    opts: {
      attempts: 3,
      backoff: { type: 'exponential', delay: 2000 },
      removeOnComplete: 1000, // Keep last 1000 completed jobs
      removeOnFail: 5000,     // Keep last 5000 failed jobs
    },
  }));

  await profileQueue.addBulk(jobs);
  console.log(`Enqueued ${jobs.length} profile fetch jobs`);
}

Step 3: The Workers

Workers pull jobs from the queue, fetch data, and push results downstream.

// Profile fetch worker
const profileWorker = new Worker('fetch-profiles', async (job) => {
  const { username, platform } = job.data;

  // Fetch profile data from SociaVault API
  const profile = await fetchWithRetry(`/${platform}/profile/${username}`);

  // Save to database
  await upsertProfile(platform, profile);

  // Enqueue post fetching as the next step
  await postQueue.add(`posts:${platform}:${username}`, {
    username,
    platform,
    followerCount: profile.followerCount,
  });

  return { username, followers: profile.followerCount };
}, {
  connection,
  concurrency: 10,       // 10 parallel jobs
  limiter: {
    max: 20,             // Max 20 jobs
    duration: 1000,      // Per second
  },
});

// Post fetch worker
const postWorker = new Worker('fetch-posts', async (job) => {
  const { username, platform } = job.data;

  const posts = await fetchWithRetry(`/${platform}/posts/${username}`, { limit: 50 });

  // Transform and save each post
  for (const post of posts) {
    await upsertPost(platform, username, post);
  }

  return { username, postCount: posts.length };
}, {
  connection,
  concurrency: 5,
  limiter: {
    max: 10,
    duration: 1000,
  },
});

Step 4: Database Upserts (Not Inserts)

Always use upserts. Running the pipeline twice should produce the same result.

const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function upsertProfile(platform, profile) {
  await pool.query(`
    INSERT INTO profiles (platform, username, follower_count, following_count, 
                          post_count, biography, profile_pic_url, updated_at)
    VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
    ON CONFLICT (platform, username)
    DO UPDATE SET
      follower_count = EXCLUDED.follower_count,
      following_count = EXCLUDED.following_count,
      post_count = EXCLUDED.post_count,
      biography = EXCLUDED.biography,
      profile_pic_url = EXCLUDED.profile_pic_url,
      updated_at = NOW()
  `, [
    platform,
    profile.username,
    profile.followerCount,
    profile.followingCount,
    profile.postCount,
    profile.biography,
    profile.profilePicUrl,
  ]);
}

async function upsertPost(platform, username, post) {
  await pool.query(`
    INSERT INTO posts (platform, post_id, username, caption, like_count, 
                       comment_count, share_count, created_at, updated_at)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
    ON CONFLICT (platform, post_id)
    DO UPDATE SET
      like_count = EXCLUDED.like_count,
      comment_count = EXCLUDED.comment_count,
      share_count = EXCLUDED.share_count,
      updated_at = NOW()
  `, [
    platform,
    post.id,
    username,
    post.caption,
    post.likeCount,
    post.commentCount,
    post.shareCount,
    new Date(post.createdAt),
  ]);
}

Why upserts matter: If a job fails halfway and retries, you don’t get duplicates. The pipeline is idempotent.

Step 5: The Scheduler

const cron = require('node-cron');

// Refresh all tracked profiles daily at 2 AM
cron.schedule('0 2 * * *', async () => {
  console.log('Starting daily profile refresh...');

  const { rows } = await pool.query(
    'SELECT platform, username FROM tracked_profiles WHERE active = true'
  );

  console.log(`Refreshing ${rows.length} profiles`);

  const byPlatform = {};
  for (const row of rows) {
    if (!byPlatform[row.platform]) byPlatform[row.platform] = [];
    byPlatform[row.platform].push(row.username);
  }

  for (const [platform, usernames] of Object.entries(byPlatform)) {
    await enqueueProfiles(usernames, platform);
  }
});

// Fetch posts for high-priority profiles every 6 hours
cron.schedule('0 */6 * * *', async () => {
  const { rows } = await pool.query(
    'SELECT platform, username FROM tracked_profiles WHERE priority = $1 AND active = true',
    ['high']
  );

  for (const row of rows) {
    await postQueue.add(`posts:${row.platform}:${row.username}`, {
      username: row.username,
      platform: row.platform,
    });
  }
});

Step 6: Monitoring

A pipeline you don’t monitor is a pipeline that’s broken and you don’t know it.

// Event listeners for monitoring
profileWorker.on('completed', (job, result) => {
  console.log(`✅ ${job.name} — ${result.followers} followers`);
});

profileWorker.on('failed', (job, err) => {
  console.error(`❌ ${job.name} — ${err.message}`);
});

// Health check endpoint
const express = require('express');
const app = express();

app.get('/health', async (req, res) => {
  const profileWaiting = await profileQueue.getWaitingCount();
  const profileActive = await profileQueue.getActiveCount();
  const profileFailed = await profileQueue.getFailedCount();

  const postWaiting = await postQueue.getWaitingCount();
  const postActive = await postQueue.getActiveCount();
  const postFailed = await postQueue.getFailedCount();

  res.json({
    status: 'ok',
    queues: {
      profiles: { waiting: profileWaiting, active: profileActive, failed: profileFailed },
      posts: { waiting: postWaiting, active: postActive, failed: postFailed },
    },
  });
});

app.listen(3001);

Performance Numbers

Running this pipeline on a $20/month VPS:

Metric “Fetch and Save” Loop Pipeline
Profiles/hour ~500 ~15,000
Error recovery Manual restart Automatic retry
Duplicate handling None Upsert
Monitoring None Health endpoint
Failure visibility “It stopped working” Exact job + error

30x throughput. Plus it fixes itself when things go wrong.

Read the Full Guide

This is a condensed version. The full guide includes:

  • Historical data tracking and change detection
  • Webhook notifications for data changes
  • Multi-region deployment for lower latency
  • Cost optimization strategies

Read the complete guide on SociaVault →

Want the data without building the pipeline? SociaVault provides ready-to-use social media data APIs for TikTok, Instagram, YouTube, and 10+ platforms. Skip the infrastructure. Just fetch the data.

Discussion

What’s the most painful part of your data pipeline? Rate limits? Data quality? Monitoring? Let’s compare war stories 👇

webdev #api #nodejs #database #backend