Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
154296 stories
·
33 followers

How to Use Dart Cloud Functions and the Firebase Admin SDK: A Handbook for Developers

1 Share

There is a specific kind of friction that every Flutter developer who has tried to write a backend has felt. You spend your days writing expressive, null-safe, strongly typed Dart code on the frontend. Your models are clean. Your async/await chains read like prose. Your type system catches entire categories of bugs before they run. Then you open a new tab to write a Cloud Function, and suddenly you are in a TypeScript file, re-declaring the same User model you just defined in Dart, manually keeping the two versions in sync, and debugging a cannot read property of undefined error that your Dart compiler would have caught in milliseconds.

This friction was not a minor inconvenience. It was a fundamental structural tax on Flutter developers who wanted to own their full stack. You maintained two codebases in two languages with two concurrency models, two type systems, two package ecosystems, and two sets of tooling. Every change to a shared data shape required two edits. Every bug in the data contract between client and server required you to mentally context-switch between languages to trace. Teams building Flutter apps with Firebase backends often hired backend developers specifically because the JavaScript cognitive overhead was too steep for a mobile-focused team.

That changes now. Cloud Functions for Firebase has announced experimental support for Dart, and alongside it, an experimental Dart Admin SDK that lets you interact with Firestore, Authentication, Cloud Storage, and other Firebase services from your function code. You can write your backend in the same language as your frontend, share data models and validation logic in a common Dart package that both sides import, and deploy your server code with the same firebase CLI you already use. The dream of a unified Dart stack, which developers had been requesting for years, is officially here.

This handbook is a complete engineering guide to that unified stack. It covers how Dart Cloud Functions work, how they differ from Node.js functions in architecture and deployment, how the Admin SDK connects your function to Firebase services, how to share logic between your Flutter app and your backend using a common Dart package, how to call your functions from Flutter, and every current limitation you need to know before betting production workloads on an experimental feature. This is not a five-minute quickstart. It is the guide for teams making the decision about whether and how to build real products with Dart on the server.

By the end, you will understand the full-stack Dart architecture from first principles, know how to set up, write, emulate, and deploy Dart Cloud Functions, understand the Admin SDK's capabilities, build a shared package that eliminates data model duplication, and make a clear-eyed decision about when this experimental feature is ready for your production use case.

Table of Contents

Prerequisites

Before working through this handbook, you should have the following foundations in place. This guide does not assume expertise in cloud infrastructure, but it does build on Flutter and Firebase knowledge throughout.

Flutter and Dart proficiency. You should be comfortable writing multi-file Dart applications, working with async/await and Future, understanding Dart's null safety system, and managing packages with pub. Experience with building Flutter apps is expected because the end-to-end examples call functions from a Flutter client. If you have shipped a Flutter app to any store, you are ready.

Firebase fundamentals. You should have used Firebase before: created a project in the Firebase Console, connected it to a Flutter app using the FlutterFire CLI, and ideally used at least one Firebase service like Firestore or Authentication. You do not need prior Cloud Functions experience, though familiarity with the concept of serverless functions will help.

Command line comfort. The entire Dart Cloud Functions workflow happens in the terminal. You need to be comfortable running commands, reading terminal output, and navigating your filesystem from the command line.

Billing plan awareness. Deploying Cloud Functions of any kind to production requires your Firebase project to be on the Blaze (pay-as-you-go) billing plan. The Firebase Local Emulator Suite lets you develop and test functions without a billing account, so you can follow most of this guide locally without cost. However, be aware that deployment requires Blaze.

Tools to have ready. Ensure the following are installed and accessible from your terminal before you begin:

  • Flutter SDK 3.x or higher (which includes Dart SDK 3.x)

  • Firebase CLI version 15.15.0 or higher (run firebase --version to check; update with npm install -g firebase-tools)

  • Node.js 18 or higher (required by the Firebase CLI, not by your Dart code)

  • A code editor with the Dart plugin (VS Code with the Dart extension, or Android Studio)

  • A Firebase project created in the Firebase Console

Packages this guide uses. Your functions directory pubspec.yaml will include:

dependencies:
  firebase_functions: ^0.1.0
  google_cloud_firestore: ^0.1.0

firebase_functions is the core Dart package that provides fireUp, the registration APIs for onRequest and onCall, and the types used throughout your function code. google_cloud_firestore is the standalone Dart Firestore SDK used exclusively on the server side inside your Cloud Functions. It is not the same package as the cloud_firestore package you use in your Flutter app. They both talk to Firestore, but they are different libraries designed for different environments: one for a Flutter client running under Firebase Security Rules, the other for a server-side process running with full admin access.

Your shared package (covered in depth later) will have no Firebase dependencies. Your Flutter app's pubspec.yaml will continue to use the standard firebase_core, cloud_firestore, and other FlutterFire packages it already uses.

A critical note on the experimental status of this feature. Everything in this guide is based on the experimental Dart support announced at Google Cloud Next 2026. Experimental means the API may change without notice, some features available in Node.js functions are not yet available in Dart, and the Firebase Console does not yet display Dart functions. You view and manage them through the Cloud Run functions page in the Google Cloud Console instead. This is genuinely new territory, and the team is actively developing it. The guide will clearly mark every limitation as it is encountered so you always know exactly where the boundaries are.

What Are Cloud Functions and Why Does Dart Change Everything?

What Cloud Functions Are

Cloud Functions for Firebase is a serverless compute platform. "Serverless" means you write a function, deploy it, and Google manages everything else: the servers, the scaling, the load balancing, the operating system updates, and the availability. You pay only for the compute time your functions actually use, measured in fractions of a second, and your functions scale automatically from zero requests to millions without any infrastructure configuration on your part.

The value proposition is straightforward. Without Cloud Functions, adding backend logic to a Flutter app meant either running your own server (expensive, complex to manage) or stuffing business logic into the client (insecure, harder to change without a store update). Cloud Functions gives you a lightweight, secure, scalable backend layer that you can update independently of your app and that can talk to every Firebase service with elevated privileges the client should never have.

Before Dart support, your options for writing Cloud Functions were JavaScript, TypeScript, Python, Java, Go, and Ruby. For Flutter developers, all of those meant context-switching out of Dart, learning a new language's ecosystem and tooling, and duplicating shared logic between the client and server. Now Dart is on that list, and because your Flutter app is already Dart, the implications run deep.

The Unified Stack: What Actually Changes

The obvious change is language. You write .dart files instead of .ts or .py files. But the deeper change is about shared code.

In a TypeScript + Flutter architecture, your User model exists twice. One version in TypeScript on the server defines the shape that Firestore documents take and what the function returns. One version in Dart on the client defines how the Flutter app parses and displays user data. When a field changes, you update both. When a developer forgets to update both, a bug is born. That bug is often invisible in development because the server and client are usually built and tested separately, and it only surfaces in integration testing or in production.

In a full-stack Dart architecture, your User model exists once, in a shared Dart package that both the function and the Flutter app import. Change it in one place and both sides immediately reflect the update. The Dart analyzer enforces that both sides use the type correctly. A field rename is a refactor you run once, with the IDE doing the renaming across the entire codebase simultaneously, and the compiler verifying the result.

Diagram of What Actually Changed

This diagram shows the core architectural difference. On the left, both sides of the stack define a User independently, meaning a change to one does not automatically enforce a change to the other. On the right, both sides import from a single shared package. The model exists once. The Dart compiler validates both uses at the same time, making drift structurally impossible rather than just carefully guarded against.

Why Dart Fits the Serverless Model Particularly Well

Dart is an ahead-of-time (AOT) compiled language, which means it compiles to native binary code before it runs rather than being interpreted at runtime. This property has a direct impact on one of the most discussed problems with serverless functions: cold starts.

A cold start happens when your function has been idle and a new request arrives. The platform needs to spin up a fresh instance, and if that requires loading a heavy runtime (as Node.js does) or a virtual machine (as Java does), the first request after a period of inactivity can take multiple seconds. In contrast, a Dart function compiles to a native binary with no runtime overhead. The cold start time for a Dart function is significantly lower than for equivalent Node.js or Python functions, making it better suited to workloads where latency on the first request matters.

The deployment process reflects this architecture. When you deploy a Dart function, the Firebase CLI does not upload your source code to be compiled in the cloud the way Node.js deployments work. It compiles your Dart code to a native binary on your development machine, then uploads that binary directly to Cloud Run. This means your machine needs the Dart SDK to build (which it already has if you develop Flutter), and it means the binary that runs in production is identical to what you tested locally.

The Problem This Solves: Life Before Dart on the Server

The Language Tax on Flutter Teams

Before this feature, a Flutter team that wanted a backend faced a real organizational choice. They could hire a backend developer who knew TypeScript or Python and create a permanent two-language split in the codebase. They could ask Flutter developers to learn TypeScript or Python well enough to write production backend code, which takes significant time and results in backend code written by people who are not experts in the backend language. Or they could avoid a custom backend entirely, trying to fit their entire product into what Firebase's client SDKs could do directly, which sometimes meant moving sensitive business logic into the client where it could be read and manipulated.

None of these choices was good. Each one was a tax on productivity, code quality, or product integrity, paid continuously as long as the split existed.

The Data Contract Problem

Even beyond the language switch, the data contract between a Flutter client and a TypeScript backend had to be maintained manually. Every API call between client and server involved a data shape that both sides needed to agree on. In practice, what happened was one of the following: the contract was documented in a README that fell out of date immediately, the contract was enforced through shared OpenAPI or protobuf schemas that added significant tooling complexity, or the contract was informal and bugs were caught in integration testing or, worse, in production.

Dart's type system, shared across both sides of the call, eliminates this problem structurally. The contract is the Dart type. The Dart compiler enforces it on both sides simultaneously. There is no README to maintain and no schema to generate.

The Tooling Gap

Flutter developers working in Dart have a rich, integrated development experience: a powerful static analyzer, hot reload, excellent IDE tooling, dart fix for automated code fixes, and a package ecosystem on pub.dev that covers most common needs. When those same developers moved to TypeScript for backend code, they left behind a familiar tooling environment and entered one that required its own configuration, its own formatter, its own linter setup, and its own dependency management. The cognitive overhead was real, and for teams where every developer wore multiple hats, it was a source of ongoing friction.

With Dart on the server, the same dart analyze, dart format, and dart pub commands work on both the Flutter app and the Cloud Functions code. The same IDE extensions apply. The same team knowledge applies.

How Dart Cloud Functions Work: Core Architecture

The Entry Point and fireUp

Every Dart Cloud Function starts from a single entry point file, by convention functions/bin/server.dart. The main function calls fireUp, which is the initialization function provided by the firebase_functions package. fireUp sets up the HTTP server that receives incoming requests and routes them to the appropriate handler, initializes the Firebase Admin SDK automatically using Google Application Default Credentials, and starts listening for requests on the correct port.

// functions/bin/server.dart

import 'package:firebase_functions/firebase_functions.dart';

void main(List<String> args) async {
  await fireUp(args, (firebase) {
    firebase.https.onRequest(
      name: 'helloWorld',
      options: const HttpsOptions(cors: Cors(['*'])),
      (request) async {
        return Response.ok('Hello from Dart Cloud Functions!');
      },
    );
  });
}

fireUp is the runtime bootstrap provided by the firebase_functions package. The first argument, args, is the list of command-line arguments that the Cloud Functions environment passes when it starts your binary, which includes the port to listen on and other runtime configuration. fireUp parses those arguments and uses them to configure the underlying Shelf HTTP server. The second argument is a callback that receives a firebase object, which is your handle to everything the Cloud Functions runtime provides. Inside that callback is where you register all your functions. firebase.https exposes the two registration methods: onRequest for raw HTTP functions and onCall for callable functions. The name parameter is the identifier for this function, which appears in Cloud Run logs and is used to route requests. HttpsOptions with cors: Cors(['*']) tells the runtime to allow cross-origin requests from any domain, which is appropriate during development but should be restricted to specific domains in production. Response.ok(...) returns an HTTP 200 response with the given body text.

HTTP Functions with onRequest

An HTTP function responds to raw HTTP requests. It is the most flexible function type because you have full control over the request and response: you can inspect headers, parse any body format, and return any HTTP response code and body.

firebase.https.onRequest(
  name: 'getUserProfile',
  options: const HttpsOptions(
    cors: Cors(['https://yourapp.com', 'https://staging.yourapp.com']),
    minInstances: 0,
  ),
  (request) async {
    if (request.method != 'GET') {
      return Response(405, body: 'Method not allowed');
    }

    final userId = request.url.queryParameters['userId'];

    if (userId == null || userId.isEmpty) {
      return Response(400, body: 'userId query parameter is required');
    }

    try {
      final doc = await firebase.adminApp
          .firestore()
          .collection('users')
          .doc(userId)
          .get();

      if (!doc.exists) {
        return Response(404, body: 'User not found');
      }

      return Response.ok(
        jsonEncode(doc.data()),
        headers: {'content-type': 'application/json'},
      );
    } catch (e) {
      return Response.internalServerError(body: 'Failed to fetch user profile');
    }
  },
);

cors: Cors([...]) explicitly lists the domains allowed to call this function from a browser. Restricting this to your actual app domains in production prevents other websites from making requests to your backend on behalf of your users. minInstances: 0 means no instances are kept warm, so the function can experience a cold start after a period of inactivity. Setting this to 1 or higher keeps instances alive at all times, which eliminates cold starts but incurs cost even when no requests are being handled. request.method is the HTTP verb of the incoming request, checked here to enforce that this endpoint only accepts GET requests. request.url.queryParameters gives you the parsed query string as a Map<String, String>. Response(405, ...) constructs an HTTP response with a specific status code. Response.ok(...) is a convenience constructor for a 200 response. headers: {'content-type': 'application/json'} tells the caller that the body is JSON, which is important for any client that uses content negotiation. Response.internalServerError(...) returns a 500 status, used here in the catch block to avoid exposing internal error details to callers.

Callable Functions with onCall

A callable function is a special kind of HTTP function designed for direct invocation from a Firebase client SDK. Unlike raw HTTP functions, callables automatically handle Firebase Authentication context: if the calling client has a signed-in user, the function receives the user's UID and token claims without you needing to parse the Authorization header manually.

firebase.https.onCall(
  name: 'createPost',
  options: const CallableOptions(
    cors: Cors(['*']),
  ),
  (request, response) async {
    if (request.auth == null) {
      throw FirebaseFunctionsException(
        code: 'unauthenticated',
        message: 'You must be signed in to create a post.',
      );
    }

    final uid = request.auth!.uid;

    final data = request.data as Map<String, dynamic>;
    final title = data['title'] as String?;
    final content = data['content'] as String?;

    if (title == null || title.trim().isEmpty) {
      throw FirebaseFunctionsException(
        code: 'invalid-argument',
        message: 'Post title is required.',
      );
    }

    if (content == null || content.trim().isEmpty) {
      throw FirebaseFunctionsException(
        code: 'invalid-argument',
        message: 'Post content is required.',
      );
    }

    final postRef = await firebase.adminApp
        .firestore()
        .collection('posts')
        .add({
      'title': title.trim(),
      'content': content.trim(),
      'authorId': uid,
      'createdAt': FieldValue.serverTimestamp(),
    });

    return CallableResult({'postId': postRef.id, 'success': true});
  },
);

request.auth is automatically populated by the Firebase Functions runtime when the calling client includes a valid Firebase Authentication ID token in the request. If the caller is not authenticated, request.auth is null. Checking for null and throwing FirebaseFunctionsException with the code 'unauthenticated' is the correct pattern for rejecting unauthenticated callers. FirebaseFunctionsException is important here because when you throw one inside a callable function, the Firebase Functions runtime intercepts it and sends a structured error response that the client SDK can interpret as a typed FirebaseFunctionsException object on the Flutter side, meaning you get machine-readable error codes across the boundary without parsing raw HTTP error bodies. request.auth!.uid is the verified Firebase Authentication UID of the signed-in user, safe to use for authorization decisions because the runtime has already verified the token. request.data is the payload sent by the Flutter client, deserialized from the request body into a Map<String, dynamic>. CallableResult(...) wraps the return value into the format the callable protocol expects, which the Flutter client receives as HttpsCallableResult.data.

The Current Limitations: What You Must Know

This is one of the most important sections in the handbook, and it must be read carefully before making architecture decisions.

Only onRequest and onCall can be deployed. Background triggers (Firestore document triggers, Authentication triggers, Pub/Sub triggers, Cloud Storage triggers, and Scheduled functions) can be run inside the local emulator for development purposes, but they cannot be deployed to production in the current experimental release. If your architecture depends on a Firestore trigger that runs when a document is created, you need to keep that trigger in a Node.js function for now and write only the business logic that does not require background triggers in Dart.

httpsCallable cannot call Dart callable functions by name. The standard Firebase client SDK method FirebaseFunctions.instance.httpsCallable('functionName') identifies functions by their name on the server. This identification mechanism does not work with Dart functions in the current release. Instead, you must use httpsCallableFromURL and pass the full Cloud Run URL of your function, which you receive when you deploy it. This is a meaningful workflow difference that affects how you configure your Flutter client.

The Firebase Console does not display Dart functions. When you deploy a Dart function and then open the Firebase Console's Functions section, you will not see it. You must go to the Cloud Run functions page in the Google Cloud Console to see, manage, and monitor your deployed Dart functions. This is a tooling gap that will likely be closed as the feature graduates from experimental status.

Diagram of Current Dart Cloud Functions Support Matrix

This table is the single most important reference when planning your architecture. Read the "Deployed to Production" column before committing to Dart for any function that relies on a trigger type listed as "No". Designing around a limitation you discover at deployment time is far more painful than designing around one you know about upfront.

The Firebase Admin SDK for Dart

What the Admin SDK Is

The Firebase Admin SDK is a set of server-side libraries that let your function code interact with Firebase services with elevated privileges. The client SDKs used by your Flutter app operate under Firebase Security Rules: a user can only read documents they are authorized to read, can only write to fields they are allowed to modify, and so on. The Admin SDK bypasses security rules entirely. It operates with full administrative access to your Firebase project.

This is why Admin SDK code must never run on the client. It runs only in secure server environments (Cloud Functions, Cloud Run, your own server) where the credentials granting admin access are protected. In Cloud Functions, the Admin SDK is initialized automatically using the function's service account, with no additional configuration required from you.

Automatic Initialization in Cloud Functions

When your Dart function runs inside the Cloud Functions environment, the Admin SDK initializes itself automatically using Google Application Default Credentials. These credentials are the function's attached service account, which has admin access to your Firebase project. You do not configure credentials, load a service account JSON file, or call any initialization function. It just works.

await fireUp(args, (firebase) {
  firebase.https.onRequest(
    name: 'adminExample',
    (request) async {
      final sensitiveDoc = await firebase.adminApp
          .firestore()
          .collection('admin_only')
          .doc('config')
          .get();

      return Response.ok(jsonEncode(sensitiveDoc.data()));
    },
  );
});

firebase.adminApp is the pre-initialized Admin SDK instance. It is available immediately inside the fireUp callback because fireUp handles initialization before your callback runs, using the service account that Cloud Run attaches to your function's execution environment. firebase.adminApp.firestore() returns a Firestore instance that operates with full admin access, bypassing every Security Rule in your database. collection('admin_only').doc('config').get() reads a document from a collection that a regular client SDK user would never be able to access, because the Security Rule protecting it would block them. The Admin SDK has no such restriction. This is the power and the responsibility of server-side code: it can read and write anything, which is why it must never run in the client.

Firestore Operations with the Admin SDK

The Dart Admin SDK provides a Firestore API that covers reads, writes, updates, deletes, queries, and batch operations. The API is structurally similar to the client-side cloud_firestore Flutter package, which makes it immediately familiar, though it is not identical.

// Reading a single document
final docRef = firebase.adminApp
    .firestore()
    .collection('posts')
    .doc(postId);

final snapshot = await docRef.get();

if (!snapshot.exists) {
  return Response(404, body: 'Post not found');
}

final data = snapshot.data()!;
final title = data['title'] as String;
final authorId = data['authorId'] as String;

firebase.adminApp.firestore().collection('posts').doc(postId) builds a reference to a specific document without performing any network call. The reference is a lightweight object that describes a path in Firestore. .get() is where the actual network call happens. It returns a DocumentSnapshot whose .exists property tells you whether a document with this ID exists. snapshot.data() returns the document's fields as Map<String, dynamic>?, which is null if the document does not exist. The ! after data() is a null assertion that is safe here because you checked .exists on the line above. Casting data['title'] as String extracts the individual field with the Dart type you expect.

// Writing a new document with a server-generated ID
final newPostRef = await firebase.adminApp
    .firestore()
    .collection('posts')
    .add({
  'title': 'My Post',
  'authorId': uid,
  'createdAt': FieldValue.serverTimestamp(),
});

final newPostId = newPostRef.id;

.add({...}) creates a new document in the collection and lets Firestore generate a random unique ID for it. It returns a DocumentReference pointing to the newly created document. newPostRef.id gives you that generated ID, which you typically return to the client so it can navigate to or reference the new document. FieldValue.serverTimestamp() is a sentinel value that tells Firestore to replace this field with the server's current timestamp at the moment the write is committed, rather than using any clock from the client or from your function code. This ensures timestamps are always accurate regardless of system clock differences.

// Updating specific fields in an existing document
await firebase.adminApp
    .firestore()
    .collection('posts')
    .doc(postId)
    .update({
  'likeCount': FieldValue.increment(1),
  'lastModified': FieldValue.serverTimestamp(),
});

.update({...}) modifies only the fields you specify and leaves every other field in the document unchanged. This is the correct operation when you want to change a subset of fields. .set({...}) would replace the entire document with only the fields you provide, deleting any fields you did not include. FieldValue.increment(1) is another Firestore sentinel that atomically increments a numeric field by the given amount. This is safe for concurrent writes because Firestore handles the increment atomically on the server, preventing the race condition you would get if you read the current value, added one in your function, and wrote the result back.

// Querying with filters and ordering
final querySnapshot = await firebase.adminApp
    .firestore()
    .collection('posts')
    .where('authorId', isEqualTo: uid)
    .orderBy('createdAt', descending: true)
    .limit(10)
    .get();

final posts = querySnapshot.docs.map((doc) {
  return {'id': doc.id, ...doc.data()};
}).toList();

.where('authorId', isEqualTo: uid) filters the query to only return documents where the authorId field matches the given uid. Multiple .where() calls can be chained to add additional filters. .orderBy('createdAt', descending: true) sorts the results by the createdAt field, newest first. When you use orderBy on a field, Firestore requires that field to be indexed, which it handles automatically for simple queries. .limit(10) caps the result set at ten documents to prevent unbounded reads. querySnapshot.docs is the list of DocumentSnapshot objects matching the query. Mapping each doc to {'id': doc.id, ...doc.data()} combines the auto-generated document ID (which is not stored inside the document's fields) with the document's field data into a single map.

// Batch writes: multiple operations committed atomically
final batch = firebase.adminApp.firestore().batch();

batch.set(
  firebase.adminApp.firestore().collection('posts').doc(newPostId),
  {'title': 'New Post', 'authorId': uid},
);

batch.update(
  firebase.adminApp.firestore().collection('users').doc(uid),
  {'postCount': FieldValue.increment(1)},
);

await batch.commit();

firestore().batch() creates a WriteBatch that accumulates multiple write operations before sending them to Firestore together. batch.set(...) and batch.update(...) queue operations without executing them immediately. batch.commit() is where all queued operations are sent to Firestore and executed atomically: if any operation fails, all of them are rolled back. This is the correct pattern whenever your business logic requires multiple documents to change together as a single unit, such as creating a post while simultaneously incrementing the author's post count. Without a batch, a crash between the two operations would leave your database in an inconsistent state.

Authentication Operations with the Admin SDK

The Admin SDK gives your functions the ability to verify ID tokens, look up users by UID or email, create and delete users, and set custom claims on user tokens. These operations require admin privileges that the client SDK cannot have.

firebase.https.onRequest(
  name: 'securedEndpoint',
  (request) async {
    final authHeader = request.headers['authorization'];

    if (authHeader == null || !authHeader.startsWith('Bearer ')) {
      return Response(401, body: 'Unauthorized');
    }

    final idToken = authHeader.substring(7);

    try {
      final decodedToken = await firebase.adminApp
          .auth()
          .verifyIdToken(idToken);

      final uid = decodedToken.uid;

      return Response.ok(jsonEncode({'uid': uid, 'success': true}));
    } on FirebaseAuthException catch (e) {
      return Response(401, body: 'Invalid or expired token: ${e.message}');
    }
  },
);

request.headers['authorization'] reads the Authorization header from the incoming HTTP request. Firebase Authentication ID tokens are sent as Bearer tokens, meaning the header value is the string "Bearer " followed by the token. .startsWith('Bearer ') validates the format before attempting to extract the token. .substring(7) strips the "Bearer " prefix (7 characters) to get the raw token string. firebase.adminApp.auth().verifyIdToken(idToken) sends the token to Firebase's token verification service, which validates the signature, checks that it has not expired, and confirms it was issued by your Firebase project. If verification succeeds, it returns a DecodedIdToken containing the user's UID and any custom claims. If the token is invalid or expired, it throws a FirebaseAuthException, which you catch and translate into a 401 response. This pattern applies specifically to onRequest functions where you need to know who the caller is. For onCall functions, this entire flow is handled automatically by the runtime, which is one of the main advantages of using callable functions over raw HTTP functions.

await firebase.adminApp
    .auth()
    .setCustomUserClaims(uid, {'role': 'admin', 'premiumUser': true});

setCustomUserClaims(uid, {...}) attaches arbitrary key-value data to a user's Firebase Authentication token. This data is included in every ID token that user subsequently obtains, making it available both in your Admin SDK code as decodedToken.claims and in Firestore Security Rules as request.auth.token.role. Custom claims are the standard way to implement role-based access control in Firebase applications. The claims take effect the next time the user's token is refreshed, which happens automatically every hour, or you can force a refresh by calling user.getIdToken(true) on the client.

Setting Up Dart Cloud Functions: Step by Step

Step 1: Enabling the Experimental Feature

Because Dart support is experimental, it is gated behind a feature flag in the Firebase CLI. You must enable the flag before the CLI will offer Dart as an option during setup.

firebase experiments:enable dartfunctions

This command writes a flag to your local Firebase CLI configuration file. It is a one-time setup step that persists across projects and terminals on the same machine.

firebase experiments

Running this command lists all currently enabled experiments, letting you confirm that dartfunctions appears in the output before proceeding. If it does not appear, the firebase init functions command in the next step will not offer Dart as a language option, which is the most common first-time setup failure.

Step 2: Verifying Your CLI Version

Dart Cloud Functions require Firebase CLI version 15.15.0 or higher.

firebase --version

This command prints the currently installed CLI version. If the output is below 15.15.0, run the update command before continuing.

npm install -g firebase-tools

This updates the Firebase CLI to the latest version globally on your machine. The -g flag installs it globally so the firebase command is accessible from any directory.

firebase login

Re-logging in after a CLI update ensures your authentication credentials are fresh and associated with the correct Google account. Skip this if you already logged in recently and are confident your credentials are current.

Step 3: Initializing Cloud Functions with Dart

firebase init functions

When the CLI prompts for a language, select Dart. When it asks whether to install dependencies now, select Yes. The CLI generates the following structure:

Diagram of project structure

functions/bin/server.dart is the entry point. The Firebase CLI knows to look here because firebase.json is configured to point to it. functions/lib/ is where you put additional Dart files that server.dart imports, keeping your function logic organized as the number of functions grows. functions/pubspec.yaml is the Dart package manifest for the functions codebase, separate from the Flutter app's pubspec.yaml. firebase.json is updated by the CLI to include the functions configuration, including the path to the compiled binary and the runtime settings.

The generated server.dart contains a working "Hello World" function you can run immediately to verify the setup:

import 'package:firebase_functions/firebase_functions.dart';

void main(List<String> args) async {
  await fireUp(args, (firebase) {
    firebase.https.onRequest(
      name: 'helloWorld',
      options: const HttpsOptions(cors: Cors(['*'])),
      (request) async {
        return Response.ok('Hello from Dart Cloud Functions!');
      },
    );
  });
}

This is a minimal but complete Dart Cloud Function. The main function receives the command-line args array, which the Cloud Functions runtime passes when it starts the binary, then hands them to fireUp which reads the port configuration from them. The onRequest registration gives the function a name and a handler that responds to every HTTP request with a 200 status and a plain text body. Running this locally verifies that the emulator can compile and start your function before you invest time in more complex logic.

Step 4: Running the Local Emulator

firebase emulators:start

The emulator starts and outputs something like:

Image of Emulator Starting

firebase emulators:start starts all emulators configured in your firebase.json. The Dart emulator compiles your function locally before starting the server, which is why you see the "Dart emulator ready" line after a brief build step. The functions emulator runs at port 5001 by default. The Firestore emulator runs at port 8080, and your function code automatically connects to the emulated Firestore rather than the production database when running inside the emulator. Your helloWorld function is callable at http://127.0.0.1:5001/your-project-id/us-central1/helloWorld. A notable advantage of the Dart emulator is hot reload: when you save changes to your .dart files, the emulator detects the change and automatically recompiles and restarts your function without you running any command.

Step 5: Connecting Your Flutter App to the Emulator

import 'package:cloud_functions/cloud_functions.dart';

void _connectToEmulators() {
  FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001);
}

useFunctionsEmulator('localhost', 5001) tells the Flutter app's Firebase Functions client to send all function calls to the local emulator at port 5001 instead of to production. Call this before any function call is made in your app, typically in main() immediately after Firebase.initializeApp(). This method only affects function calls, not Firestore or Authentication, which have their own equivalent methods if you want to emulate those too.

if (Platform.isAndroid) {
  FirebaseFunctions.instance.useFunctionsEmulator('10.0.2.2', 5001);
} else {
  FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001);
}

The Android emulator runs inside a virtual machine that has its own network namespace. From the Android emulator's perspective, localhost refers to the emulator itself, not to your development machine. The special address 10.0.2.2 is how the Android emulator reaches the host machine's localhost. iOS simulators do not have this issue because they share the host machine's network, so localhost works correctly there. The Platform.isAndroid check selects the correct address at runtime, allowing the same code to work correctly on both platforms during development.

Step 6: Deploying to Production

firebase deploy --only functions

The --only functions flag tells the CLI to deploy just the functions and skip any other Firebase resources (Firestore rules, Hosting, and so on). The deployment process for Dart is meaningfully different from Node.js: the Firebase CLI runs dart compile exe on your development machine, producing a native binary. It then uploads that binary to Cloud Run. The deployment output includes the URL of your deployed function:

✔  functions: Finished running predeploy script.
✔  functions: helloWorld(us-central1) deployed successfully.

Function URL (helloWorld(us-central1)):
  https://helloworld-abc123def456-uc.a.run.app

Save that URL. Because of the current limitation around httpsCallable name resolution, you will need to pass this URL directly when calling the function from Flutter. The hash in the URL (abc123def456) is unique to your project and function, and it does not change between deployments of the same function, so it is safe to hardcode in your Flutter app or load from Firebase Remote Config.

Calling Dart Functions from Flutter

Calling with httpsCallableFromURL

Because httpsCallable('functionName') does not work with Dart functions in the current release, you use httpsCallableFromURL with the full Cloud Run URL instead:

// lib/services/functions_service.dart

import 'package:cloud_functions/cloud_functions.dart';

class FunctionsService {
  static const _createPostUrl =
      'https://createpost-abc123def456-uc.a.run.app';

  static const _getUserProfileUrl =
      'https://getuserprofile-abc123def456-uc.a.run.app';

  Future<String> createPost({
    required String title,
    required String content,
  }) async {
    try {
      final callable = FirebaseFunctions.instance.httpsCallableFromURL(
        _createPostUrl,
      );

      final result = await callable.call({
        'title': title,
        'content': content,
      });

      return result.data['postId'] as String;
    } on FirebaseFunctionsException catch (e) {
      throw _mapFunctionException(e);
    }
  }

  Exception _mapFunctionException(FirebaseFunctionsException e) {
    switch (e.code) {
      case 'unauthenticated':
        return UnauthorizedException('Please sign in to continue.');
      case 'invalid-argument':
        return ValidationException(e.message ?? 'Invalid input.');
      case 'not-found':
        return NotFoundException(e.message ?? 'Resource not found.');
      default:
        return ServerException(
          e.message ?? 'An unexpected error occurred.',
        );
    }
  }
}

Centralizing the function URLs as static const strings at the top of the service class means they are in one place, easy to find, and easy to update. In a larger app, consider loading them from Firebase Remote Config so you can update URLs without shipping a new app version. FirebaseFunctions.instance.httpsCallableFromURL(_createPostUrl) creates a HttpsCallable object targeting the given URL. This object wraps all the protocol details of the callable function format, including serializing your data as the request body and deserializing the response. callable.call({...}) executes the function call, sends the map as the request payload, and returns a HttpsCallableResult when the function completes. result.data is the Map<String, dynamic> returned by CallableResult(...) on the server. Catching FirebaseFunctionsException captures every structured error thrown by FirebaseFunctionsException on the server. e.code is the machine-readable error code, and _mapFunctionException converts it into a typed domain exception from your app's own exception hierarchy, keeping Firebase-specific types out of your business logic.

Calling HTTP Functions Directly

For onRequest HTTP functions, you call them like any other HTTP endpoint using Dart's http package:

import 'package:http/http.dart' as http;
import 'dart:convert';

class ProfileService {
  static const _getUserProfileUrl =
      'https://getuserprofile-abc123def456-uc.a.run.app';

  Future<Map<String, dynamic>> getUserProfile(String userId) async {
    final user = FirebaseAuth.instance.currentUser;
    final idToken = await user?.getIdToken();

    final response = await http.get(
      Uri.parse('\(_getUserProfileUrl?userId=\)userId'),
      headers: {
        if (idToken != null) 'Authorization': 'Bearer $idToken',
        'Content-Type': 'application/json',
      },
    );

    if (response.statusCode == 200) {
      return jsonDecode(response.body) as Map<String, dynamic>;
    }

    throw ServerException('Failed to fetch profile: ${response.statusCode}');
  }
}

FirebaseAuth.instance.currentUser retrieves the currently signed-in user from the local Firebase Auth cache without making a network call. user?.getIdToken() fetches the user's current ID token, refreshing it if it has expired. The ? means this returns null if there is no signed-in user, which the conditional header insertion handles gracefully. if (idToken != null) 'Authorization': 'Bearer \(idToken' is Dart's collection if syntax, which conditionally includes the Authorization header only when a token is available. This lets the same service method work for both authenticated and anonymous requests by simply omitting the header when no token exists. Uri.parse('\)_getUserProfileUrl?userId=$userId') appends the query parameter to the URL. jsonDecode(response.body) as Map<String, dynamic> parses the JSON response body into a Dart map. If the status code is anything other than 200, a ServerException is thrown with the status code included for debugging.

The Shared Package: Eliminating Data Model Duplication

The shared package is the most architecturally significant part of the full-stack Dart story. It is a standalone Dart package with no Flutter dependency and no Firebase dependency that defines the data models, validation logic, constants, and utility functions used by both your Cloud Functions backend and your Flutter frontend.

Creating the Shared Package

dart create --template=package packages/shared

dart create --template=package generates a new Dart package with the standard library layout: a lib/ directory for public code, a test/ directory, and a pubspec.yaml. The packages/shared path places it inside a packages/ folder at the project root, which is the conventional location for internal packages in a mono-repository structure. After running this command, your project structure becomes:

Imag of Project Structure

The shared pubspec.yaml is intentionally minimal:

name: shared
description: Shared data models and logic for the Kopa app.
version: 0.1.0

environment:
  sdk: ^3.0.0

dependencies:
  json_annotation: ^4.8.0

dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.7.0
  test: ^1.24.0

The most important characteristic of this pubspec.yaml is what is absent: there is no flutter, no firebase_core, no firebase_functions, and no cloud_firestore. The shared package depends only on pure Dart libraries. This is what makes it importable from both the server-side functions package and the Flutter app simultaneously without causing version conflicts. json_annotation provides the @JsonSerializable() annotation used on model classes. json_serializable is a build-time code generator that reads those annotations and generates fromJson/toJson methods, listed as a dev dependency because it only runs during development, not at runtime. build_runner is the tool that executes code generators, also a dev dependency. test enables unit testing of the shared logic.

Defining Shared Models

// packages/shared/lib/src/models/post.dart

import 'package:json_annotation/json_annotation.dart';

part 'post.g.dart';

@JsonSerializable()
class Post {
  final String id;
  final String title;
  final String content;
  final String authorId;
  final int likeCount;
  final DateTime createdAt;

  const Post({
    required this.id,
    required this.title,
    required this.content,
    required this.authorId,
    required this.likeCount,
    required this.createdAt,
  });

  factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
  Map<String, dynamic> toJson() => _$PostToJson(this);
}

part 'post.g.dart' declares that a generated file named post.g.dart is part of this library. The json_serializable code generator creates this file when you run dart run build_runner build. @JsonSerializable() is the annotation that tells json_serializable to generate serialization code for this class. All fields are final because model objects should be immutable: once created, a Post does not change in place. You create a new Post with different values instead. Using DateTime for createdAt rather than a raw int timestamp or a String keeps the model at the right level of abstraction. Both the Flutter app and the function convert between DateTime and their specific timestamp formats locally, keeping the shared model free of either side's concerns. factory Post.fromJson(...) and toJson() delegate to the generated _\(PostFromJson and _\)PostToJson functions, eliminating hand-written serialization. Hand-written serialization is where most data contract bugs originate: a missed field, a wrong key name, a forgotten null check. Code generation eliminates that entire category of error.

// packages/shared/lib/src/validation/post_validation.dart

class PostValidation {
  static const int titleMaxLength = 120;
  static const int contentMaxLength = 10000;
  static const int titleMinLength = 3;

  static String? validateTitle(String? title) {
    if (title == null || title.trim().isEmpty) {
      return 'Title is required.';
    }
    if (title.trim().length < titleMinLength) {
      return 'Title must be at least $titleMinLength characters.';
    }
    if (title.trim().length > titleMaxLength) {
      return 'Title cannot exceed $titleMaxLength characters.';
    }
    return null;
  }

  static String? validateContent(String? content) {
    if (content == null || content.trim().isEmpty) {
      return 'Content is required.';
    }
    if (content.trim().length > contentMaxLength) {
      return 'Content cannot exceed $contentMaxLength characters.';
    }
    return null;
  }

  static bool isValid({required String title, required String content}) {
    return validateTitle(title) == null && validateContent(content) == null;
  }
}

All members are static because PostValidation is a namespace for functions, not a class you instantiate. The length constants titleMaxLength, contentMaxLength, and titleMinLength are static const, meaning they exist at compile time, take no memory at runtime, and can be used both in runtime validation logic and in Flutter widget configuration (for example, as the maxLength parameter of a TextField). Each validator follows Dart's convention for form validators: returning null means valid, returning a String means invalid with that error message. The validateTitle method calls .trim() before checking length to prevent whitespace-padded strings from passing length validation. The isValid convenience method allows callers who only need a boolean (as opposed to the error message) to check both fields in one call, such as for enabling or disabling a submit button.

// packages/shared/lib/src/constants/api_constants.dart

class ApiConstants {
  static const String createPostFunction = 'createPost';
  static const String getUserProfileFunction = 'getUserProfile';
  static const String likePostFunction = 'likePost';

  static const String postsCollection = 'posts';
  static const String usersCollection = 'users';
}

ApiConstants stores the string identifiers for function names and Firestore collection names that both sides of the stack reference. Using constants instead of string literals scattered across your code prevents typos and ensures that if a name changes, you update it in one place and the compiler surfaces every location that used it. Function name constants are used in firebase.https.onRequest(name: ApiConstants.createPostFunction) on the server and in URL construction or logging on the client. Collection name constants ensure the server and client always write to and read from identically named collections, preventing the class of bug where the function writes to "Posts" with a capital P and the client queries "posts" with a lowercase p.

// packages/shared/lib/shared.dart

export 'src/models/post.dart';
export 'src/models/user.dart';
export 'src/validation/post_validation.dart';
export 'src/constants/api_constants.dart';

This is the barrel file. It re-exports everything the package provides through a single import point. Consumers of the package write import 'package:shared/shared.dart' and immediately have access to Post, PostValidation, ApiConstants, and everything else the package exports. Without the barrel file, consumers would need to know the internal directory structure and import each file individually, which is a detail the package should hide.

Referencing the Shared Package from Functions

# functions/pubspec.yaml

name: kopa_functions
version: 0.1.0

environment:
  sdk: ^3.0.0

dependencies:
  firebase_functions: ^0.1.0
  google_cloud_firestore: ^0.1.0
  shared:
    path: ../packages/shared

shared: path: ../packages/shared is a path dependency. It tells the Dart pub tool to resolve the shared package from the filesystem at the given relative path rather than from pub.dev. The path ../packages/shared goes up one level from functions/ to the project root, then down into packages/shared/. When the Firebase CLI compiles your Dart functions for deployment, it resolves this path dependency locally on your development machine and bundles it into the compiled binary, so it works correctly in production despite being a local path reference.

Referencing the Shared Package from Flutter

# pubspec.yaml (Flutter app)

dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^3.0.0
  cloud_firestore: ^5.0.0
  firebase_auth: ^5.0.0
  cloud_functions: ^5.0.0
  shared:
    path: packages/shared

The Flutter app references the shared package with path: packages/shared, which is a relative path from the Flutter project root. Notice the path is packages/shared without the ../ prefix that the functions package uses, because the Flutter pubspec.yaml lives at the project root while the functions pubspec.yaml lives inside the functions/ subdirectory. Both reference the same physical directory on disk. This is the key insight: two different packages, with two different pubspec.yaml files written from two different perspectives, referencing the same source code.

Using Shared Logic in the Cloud Function

// functions/bin/server.dart

import 'dart:convert';
import 'package:firebase_functions/firebase_functions.dart';
import 'package:google_cloud_firestore/google_cloud_firestore.dart' show FieldValue;
import 'package:shared/shared.dart';

void main(List<String> args) async {
  await fireUp(args, (firebase) {
    firebase.https.onCall(
      name: ApiConstants.createPostFunction,
      (request, response) async {
        if (request.auth == null) {
          throw FirebaseFunctionsException(
            code: 'unauthenticated',
            message: 'You must be signed in.',
          );
        }

        final data = request.data as Map<String, dynamic>;
        final title = data['title'] as String?;
        final content = data['content'] as String?;

        final titleError = PostValidation.validateTitle(title);
        if (titleError != null) {
          throw FirebaseFunctionsException(
            code: 'invalid-argument',
            message: titleError,
          );
        }

        final contentError = PostValidation.validateContent(content);
        if (contentError != null) {
          throw FirebaseFunctionsException(
            code: 'invalid-argument',
            message: contentError,
          );
        }

        final ref = await firebase.adminApp
            .firestore()
            .collection(ApiConstants.postsCollection)
            .add({
          'title': title!.trim(),
          'content': content!.trim(),
          'authorId': request.auth!.uid,
          'likeCount': 0,
          'createdAt': FieldValue.serverTimestamp(),
        });

        return CallableResult({'postId': ref.id});
      },
    );
  });
}

import 'package:shared/shared.dart' pulls in the entire shared package in one line. ApiConstants.createPostFunction uses the shared constant for the function name rather than a string literal, ensuring the name the server registers matches exactly what any logging or monitoring system expects. PostValidation.validateTitle(title) and PostValidation.validateContent(content) run the exact same validation logic that the Flutter form runs on the client. Even if a malicious actor bypasses the client validation (which is always possible because client code is not trusted), the server enforces the same rules independently. ApiConstants.postsCollection is the shared collection name constant, ensuring the function writes to the same collection path the Flutter app reads from.

Using Shared Logic in the Flutter App

// lib/features/create_post/create_post_screen.dart

import 'package:flutter/material.dart';
import 'package:shared/shared.dart';

class CreatePostScreen extends StatefulWidget {
  const CreatePostScreen({super.key});

  @override
  State<CreatePostScreen> createState() => _CreatePostScreenState();
}

class _CreatePostScreenState extends State<CreatePostScreen> {
  final _titleController = TextEditingController();
  final _contentController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('New Post')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            TextFormField(
              controller: _titleController,
              decoration: const InputDecoration(labelText: 'Title'),
              validator: (value) => PostValidation.validateTitle(value),
              maxLength: PostValidation.titleMaxLength,
            ),
            const SizedBox(height: 16),
            TextFormField(
              controller: _contentController,
              decoration: const InputDecoration(labelText: 'Content'),
              validator: (value) => PostValidation.validateContent(value),
              maxLength: PostValidation.contentMaxLength,
              maxLines: 8,
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _titleController.dispose();
    _contentController.dispose();
    super.dispose();
  }
}

validator: (value) => PostValidation.validateTitle(value) passes the shared validator directly to the TextFormField's validator property. Flutter's form system calls this function when the user submits the form, and the return value is either null (valid) or an error string (invalid), exactly matching the convention PostValidation uses. maxLength: PostValidation.titleMaxLength uses the shared constant to configure the field's character limit, ensuring the UI reflects the same limit that validation enforces. If the max length is later increased from 120 to 200, updating the constant in the shared package automatically updates both the form's character counter and the validation rule that enforces it, on both client and server, in a single change.

Architecture: How the Full Stack Fits Together

The Full-Stack Dart Request Lifecycle

This diagram shows the complete journey of a single request. The Flutter app validates locally using shared logic and then makes a callable function invocation. Firebase's infrastructure receives the request, verifies the Authentication token, and routes the request to the correct Dart binary running on Cloud Run. The Dart function runs its own validation (using the same shared logic) and writes to Firestore using Admin SDK access. It returns a result that the Flutter client receives as structured data. Throughout this entire flow, every piece of code that could be shared between client and server is shared, and every piece that must be separate (Flutter widgets, Firebase Admin operations) is appropriately separated.

Project Structure for a Full-Stack Dart Project

Project Structure for a Full-Stack Dart Project

The three-directory structure at the project root is the organizing principle: lib/ for the Flutter app, functions/ for the backend, and packages/ for everything shared between them. This separation makes it immediately clear where any piece of code belongs. The services/ directory in the Flutter app is where FunctionsService and similar classes live, keeping function call logic out of widgets. The handlers/ directory inside functions/lib/ is where per-domain function logic lives, keeping server.dart clean and focused on registration only.

Advanced Concepts

Organizing Multiple Functions

As your backend grows, registering every function inside a single fireUp callback becomes unwieldy. Extract handlers into separate files and import them into the server entry point:

// functions/lib/handlers/post_handler.dart

import 'package:firebase_functions/firebase_functions.dart';
import 'package:google_cloud_firestore/google_cloud_firestore.dart' show FieldValue;
import 'package:shared/shared.dart';

void registerPostHandlers(FirebaseApp firebase) {
  firebase.https.onCall(
    name: ApiConstants.createPostFunction,
    (request, response) async {
      // handler logic
    },
  );

  firebase.https.onCall(
    name: ApiConstants.likePostFunction,
    (request, response) async {
      // handler logic
    },
  );

  firebase.https.onRequest(
    name: ApiConstants.getUserProfileFunction,
    (request) async {
      // handler logic
    },
  );
}

registerPostHandlers(FirebaseApp firebase) is a plain top-level function that accepts the firebase object and registers all post-related functions using it. The function signature FirebaseApp firebase uses the type provided by firebase_functions so the parameter is typed correctly. This approach mirrors how the main.dart of a Flutter app works: a single entry point that calls setup functions responsible for different areas of configuration.

// functions/bin/server.dart

import 'package:firebase_functions/firebase_functions.dart';
import '../lib/handlers/post_handler.dart';
import '../lib/handlers/user_handler.dart';

void main(List<String> args) async {
  await fireUp(args, (firebase) {
    registerPostHandlers(firebase);
    registerUserHandlers(firebase);
  });
}

server.dart is now a clean orchestration file. It imports the registration functions from each domain handler file and calls them in sequence inside fireUp. Adding a new domain is as simple as creating a new handler file and adding one line here. The fireUp callback is the only place where the firebase object is available, so it must be passed to every registration function that needs it.

Error Handling Patterns

Production Cloud Functions need consistent, predictable error handling. Define a centralized error handler rather than scattering try-catch blocks across every function:

// functions/lib/utils/error_handler.dart

import 'package:firebase_functions/firebase_functions.dart';

typedef CallableHandler = Future<CallableResult> Function(
  CallableRequest request,
  CallableResponse response,
);

CallableHandler withErrorHandling(CallableHandler handler) {
  return (request, response) async {
    try {
      return await handler(request, response);
    } on FirebaseFunctionsException {
      rethrow;
    } on ArgumentError catch (e) {
      throw FirebaseFunctionsException(
        code: 'invalid-argument',
        message: e.message,
      );
    } catch (e, stackTrace) {
      print('Unhandled error in function: $e');
      print(stackTrace);
      throw FirebaseFunctionsException(
        code: 'internal',
        message: 'An internal error occurred. Please try again.',
      );
    }
  };
}

typedef CallableHandler defines a Dart function type alias for the handler signature that onCall expects. This makes withErrorHandling typeable without repeating the full function signature everywhere. withErrorHandling is a higher-order function: it takes a handler function and returns a new function that wraps the original in a try-catch. on FirebaseFunctionsException { rethrow; } lets structured errors thrown intentionally in your handler pass through unchanged, because they are already in the correct format for the client. on ArgumentError catch (e) converts Dart's built-in ArgumentError (typically thrown by validation code) into a FirebaseFunctionsException with the invalid-argument code that the client can understand. The final catch (e, stackTrace) is the safety net for any unhandled exception, logging the full error internally with its stack trace while returning a sanitized message to the client that reveals nothing about the internal error.

firebase.https.onCall(
  name: 'createPost',
  withErrorHandling((request, response) async {
    if (request.auth == null) {
      throw FirebaseFunctionsException(
        code: 'unauthenticated',
        message: 'Authentication required.',
      );
    }
    return CallableResult({'success': true});
  }),
);

withErrorHandling(...) wraps the handler at registration time. The third positional argument to onCall (the handler function) is replaced by the return value of withErrorHandling, which is itself a function with the correct signature. The handler inside has no try-catch blocks of its own because withErrorHandling covers all error scenarios.

Testing Dart Cloud Functions

Cloud Functions written in Dart are plain Dart code, which means they are fully testable using standard Dart testing tools. The business logic inside your handlers can be extracted into pure functions with no Firebase dependency, then unit tested directly:

// functions/lib/handlers/post_logic.dart

import 'package:shared/shared.dart';

PostInput validateCreatePostRequest(Map<String, dynamic> data) {
  final title = data['title'] as String?;
  final content = data['content'] as String?;

  final titleError = PostValidation.validateTitle(title);
  if (titleError != null) throw ArgumentError(titleError);

  final contentError = PostValidation.validateContent(content);
  if (contentError != null) throw ArgumentError(contentError);

  return PostInput(
    title: title!.trim(),
    content: content!.trim(),
  );
}

class PostInput {
  final String title;
  final String content;
  const PostInput({required this.title, required this.content});
}

validateCreatePostRequest is a pure function: it takes a Map<String, dynamic> and either returns a PostInput or throws an ArgumentError. It has no Firebase dependencies, no async calls, and no side effects. This makes it testable with a single dart test command, no Firebase emulator required. PostInput is a simple value class that carries the validated and trimmed inputs. Returning a typed result rather than the raw map ensures that callers receive validated data in a form the compiler can reason about.

// functions/test/post_logic_test.dart

import 'package:test/test.dart';
import '../lib/handlers/post_logic.dart';

void main() {
  group('validateCreatePostRequest', () {
    test('returns valid PostInput for correct data', () {
      final result = validateCreatePostRequest({
        'title': 'Valid Title',
        'content': 'This is valid post content.',
      });

      expect(result.title, equals('Valid Title'));
      expect(result.content, equals('This is valid post content.'));
    });

    test('throws ArgumentError when title is empty', () {
      expect(
        () => validateCreatePostRequest({'title': '', 'content': 'Content'}),
        throwsA(isA<ArgumentError>()),
      );
    });

    test('throws ArgumentError when title exceeds max length', () {
      final longTitle = 'A' * 200;
      expect(
        () => validateCreatePostRequest({
          'title': longTitle,
          'content': 'Content',
        }),
        throwsA(isA<ArgumentError>()),
      );
    });

    test('trims whitespace from title and content', () {
      final result = validateCreatePostRequest({
        'title': '  Padded Title  ',
        'content': '  Padded content.  ',
      });

      expect(result.title, equals('Padded Title'));
      expect(result.content, equals('Padded content.'));
    });
  });
}

group('validateCreatePostRequest', ...) groups related tests under a shared label, producing organized output that makes it easy to find failures. Each test(...) call exercises one specific behavior: the happy path, the empty title case, the oversized title case, and the whitespace trimming case. expect(result.title, equals('Valid Title')) is the assertion: it checks that the actual value matches the expected value. throwsA(isA<ArgumentError>()) is a matcher that passes only if the callable throws an ArgumentError, which is the contract validateCreatePostRequest defines for invalid input. 'A' * 200 is a Dart string repetition that creates a 200-character string, which exceeds the titleMaxLength of 120 defined in the shared package.

cd functions
dart test

Running the function tests requires no Firebase emulator, no network access, and no special setup beyond having the Dart SDK installed. The tests complete in milliseconds.

cd packages/shared
dart test

The shared package tests run identically. Both commands use the standard dart test runner, which recursively finds and executes all files ending in _test.dart in the test/ directory.

Function Configuration Options

Both onRequest and onCall accept an options object that controls runtime behavior:

firebase.https.onRequest(
  name: 'highTrafficEndpoint',
  options: const HttpsOptions(
    cors: Cors(['https://yourapp.com']),
    minInstances: 1,
    maxInstances: 10,
    concurrency: 80,
    memory: Memory.mb512,
    timeoutSeconds: 120,
    region: 'europe-west1',
  ),
  (request) async {
    return Response.ok('Hello from a configured function!');
  },
);

minInstances: 1 keeps one instance of this function warm at all times, which completely eliminates cold starts for this function. The trade-off is that you are billed for one instance running continuously even when no requests are arriving. Use this only for functions where cold start latency is genuinely unacceptable, such as real-time features that users interact with directly. maxInstances: 10 caps the number of concurrent instances at ten. This prevents a sudden traffic spike from scaling the function to hundreds of instances, which protects both your billing and any downstream services (like a database) that could be overwhelmed by sudden high concurrency. concurrency: 80 tells Cloud Run how many simultaneous requests a single instance will handle. Dart's async model handles concurrent I/O-bound requests efficiently without threads, so this can be set higher than for Node.js. memory: Memory.mb512 allocates 512 megabytes of RAM to each function instance. Increase this for memory-intensive operations like image processing or loading large datasets. CPU allocation scales proportionally with memory, so increasing memory also increases processing power. timeoutSeconds: 120 sets the maximum time a request can run before Cloud Run terminates it. Increase this for long-running operations. region: 'europe-west1' deploys this function to a Google data center in Belgium, which reduces latency for users in Europe. By default functions deploy to us-central1.

Best Practices for Production Use

Treat Experimental as Experimental

The most important practice is to calibrate your production use to the feature's actual maturity. Dart Cloud Functions are experimental. This means two specific things for production decisions.

First, the API can change without notice. A future Firebase CLI update may change how fireUp works, how functions are registered, or how the Admin SDK is accessed. Before updating the CLI in a project that uses Dart functions, read the changelog and test in a staging environment. Do not update production tooling blindly.

Second, some things simply do not work yet. Background triggers, name-based httpsCallable invocation, and Firebase Console display are all gaps in the current release. Architect around these limitations from the beginning rather than discovering them during deployment.

Keep Handlers Thin, Keep Logic Shared

The handler registered with firebase.https.onCall or firebase.https.onRequest should do as little as possible: authenticate the request, extract the input, call a pure function that does the actual work, and return the result. The pure function belongs either in the functions library or in the shared package. This structure makes the logic testable without a Firebase environment and makes it easier to move logic to the shared package later if the Flutter app needs it.

Use FieldValue.serverTimestamp() for All Timestamps

Never send a timestamp from the client or generate one in your function code using DateTime.now(). Server timestamps are set by Firestore at the moment of the write and are guaranteed to be accurate regardless of the caller's clock. Client-generated timestamps can be wrong if the user's device clock is incorrect. Function-generated DateTime.now() timestamps are accurate but miss the small window of time between function execution and the Firestore write being committed.

Log Meaningfully but Not Excessively

Cloud Functions logs are visible in the Google Cloud Console and in the Cloud Run logs. print() in Dart functions writes to these logs. Log events that are useful for debugging production issues: function invocations with their input shape (not sensitive data), successful completions with result shape, errors with the full error and stack trace, and performance-relevant events like external API calls. Do not log every line of execution or every data transformation, which floods the logs and makes real errors hard to find.

Rate Limit and Authenticate by Default

Every Cloud Function that is reachable over the internet is potentially callable by anyone who discovers its URL. Callable functions validate Firebase Authentication automatically, but HTTP functions do not. For every onRequest function that should require authentication, verify the ID token explicitly. For every function regardless of type, consider implementing per-user rate limiting before launch to prevent both accidental loops and intentional abuse.

When to Use Dart Cloud Functions and When Not To

Where Dart Cloud Functions Add Real Value

Dart Cloud Functions are most valuable when you are a Flutter-first team that wants to write backend logic without context-switching out of Dart. The shared package pattern is where the architectural value is highest: any time you have validation rules, data models, constants, or utility logic that both the client and server need, having both sides share that code in a single Dart package eliminates an entire category of data contract bugs.

Lightweight, I/O-bound API logic is a strong fit. Dart's async model is efficient for workloads that spend most of their time waiting for Firestore queries, external API calls, or other network operations, rather than doing heavy computation. A function that reads some documents from Firestore, applies business logic, and writes results back is exactly the kind of workload Dart handles well.

Mobile-backend-for-frontend patterns are a natural use case: functions that aggregate data from multiple Firestore collections into a single response shaped for a specific screen, functions that perform write operations that require multiple documents to be updated atomically, and functions that need admin access to create or update records that clients should not be able to modify directly.

Where Dart Cloud Functions Are the Wrong Choice Right Now

Background triggers are currently not deployable. If your architecture depends on functions that run when a Firestore document is created or updated, when a user signs up, on a schedule, or in response to Pub/Sub messages, you cannot use Dart for those functions today. You need to write them in Node.js or Python and wait for background trigger support to land in a future release.

Production-critical infrastructure should be evaluated carefully before committing to experimental tooling. If a function failure would result in data loss, financial errors, or significant user impact, the experimental label on Dart support is a meaningful risk factor. The API may change, behavior may change, and the Firebase team's ability to quickly address critical production bugs in an experimental feature is different from their commitment to stable features.

Highly concurrent workloads that need fine-tuned performance characteristics may benefit from testing with real traffic before committing to Dart. The performance story for Dart functions (excellent cold start, efficient async I/O handling) is theoretically strong, but production traffic can reveal edge cases that local testing does not.

Common Mistakes

Forgetting the Experiment Flag

The most common first-time problem is running firebase init functions and not seeing Dart as a language option. The fix is always the same: run firebase experiments:enable dartfunctions first, then run firebase init functions. The experiment flag must be set in the Firebase CLI before Dart becomes available as an option.

Using Relative Paths Incorrectly in pubspec.yaml

The shared package is referenced using a relative path dependency in both functions/pubspec.yaml and the Flutter app's pubspec.yaml. If the relative path is wrong (because the folder structure differs from what the codebase expected, or because the package was moved), both the function compilation and the Flutter build will fail with package resolution errors. Verify the path by running dart pub get in the functions directory and checking that it resolves without errors before deploying.

Forgetting to Handle the httpsCallable Name Limitation

The most common integration bug in the current release is calling a Dart function with FirebaseFunctions.instance.httpsCallable('functionName') and wondering why it returns a not-found error. The current release does not support name-based resolution for Dart functions. You must use httpsCallableFromURL with the full Cloud Run URL. Save the URL from the deployment output and use it explicitly in your Flutter code.

Looking for Functions in the Firebase Console

After deploying a Dart function, opening the Firebase Console's Functions section and seeing nothing is alarming if you do not know it is expected behavior. Your Dart functions are deployed to Cloud Run and are visible in the Cloud Run functions page of the Google Cloud Console, not in the Firebase Console. This is a known gap in the experimental release and will be addressed when the feature reaches general availability.

Putting Firebase Dependencies in the Shared Package

The shared package must remain dependency-free of Firebase and Flutter packages. Adding firebase_functions or cloud_firestore as a dependency of the shared package breaks the fundamental architecture: the shared package would then pull in server-side Firebase dependencies into the Flutter app or client-side Firebase dependencies into the functions, causing version conflicts and compilation errors. The shared package contains only pure Dart logic and models. Firebase interactions happen in the functions package and the Flutter app separately, both of which import the shared package.

Not Extracting Logic into Pure Functions

Putting all business logic directly inside the onCall or onRequest callback makes it impossible to unit test without a running Firebase emulator. Dart's strength is its testability. Extract validation, transformation, and business logic into pure functions in the functions library or the shared package. Test those pure functions with dart test without any Firebase infrastructure. Reserve the handler callbacks for the thin layer that connects Firebase inputs and outputs to that pure logic.

Mini End-to-End Example

Let's build a complete, working full-stack Dart application: a post creation feature with a shared model, shared validation, a Dart Cloud Function that writes to Firestore, and a Flutter screen that calls the function. This brings together every concept from the handbook in one runnable project.

The Shared Package

// packages/shared/lib/src/models/post.dart

class Post {
  final String id;
  final String title;
  final String content;
  final String authorId;
  final int likeCount;

  const Post({
    required this.id,
    required this.title,
    required this.content,
    required this.authorId,
    required this.likeCount,
  });

  factory Post.fromMap(String id, Map<String, dynamic> data) {
    return Post(
      id: id,
      title: data['title'] as String? ?? '',
      content: data['content'] as String? ?? '',
      authorId: data['authorId'] as String? ?? '',
      likeCount: data['likeCount'] as int? ?? 0,
    );
  }

  Map<String, dynamic> toMap() => {
    'title': title,
    'content': content,
    'authorId': authorId,
    'likeCount': likeCount,
  };
}

Post.fromMap takes both the document ID (which Firestore stores externally to the document data) and the document's field map, combining them into a fully populated Post instance. The as String? ?? '' pattern is a safe cast followed by a null fallback: if the field is absent or null, the empty string is used instead of throwing a null dereference error. toMap() serializes the Post into a Map suitable for writing to Firestore, intentionally excluding id because Firestore generates and stores the document ID outside the document body. The likeCount starts at zero when creating a new post and is updated by the server-side increment operation.

// packages/shared/lib/src/validation/post_validation.dart

class PostValidation {
  static const int titleMaxLength = 120;
  static const int contentMaxLength = 5000;

  static String? validateTitle(String? value) {
    if (value == null || value.trim().isEmpty) return 'Title is required.';
    if (value.trim().length > titleMaxLength) {
      return 'Title cannot exceed $titleMaxLength characters.';
    }
    return null;
  }

  static String? validateContent(String? value) {
    if (value == null || value.trim().isEmpty) return 'Content is required.';
    if (value.trim().length > contentMaxLength) {
      return 'Content cannot exceed $contentMaxLength characters.';
    }
    return null;
  }
}

This is the simplified version of PostValidation used in the end-to-end example. Both methods follow the validator contract: null means valid, a String means invalid with the given reason. The checks are ordered from most common failure (empty input) to more specific failures (too long), which is both logical and efficient since the empty check short-circuits before the length check runs.

// packages/shared/lib/src/constants/api_constants.dart

class ApiConstants {
  static const String createPost = 'createPost';
  static const String postsCollection = 'posts';
}

In the end-to-end example, ApiConstants is trimmed to just the two constants this feature needs: the function name and the collection name. This keeps the example focused. In a real application, this class would grow to include every function and collection name used across the entire app.

// packages/shared/lib/shared.dart

export 'src/models/post.dart';
export 'src/validation/post_validation.dart';
export 'src/constants/api_constants.dart';

The barrel file exports all three modules. Any file on either side of the stack that imports package:shared/shared.dart immediately has access to Post, PostValidation, and ApiConstants without needing to know which subdirectory any of them lives in.

The Cloud Function

// functions/bin/server.dart

import 'dart:convert';
import 'package:firebase_functions/firebase_functions.dart';
import 'package:google_cloud_firestore/google_cloud_firestore.dart' show FieldValue;
import 'package:shared/shared.dart';

void main(List<String> args) async {
  await fireUp(args, (firebase) {
    firebase.https.onCall(
      name: ApiConstants.createPost,
      options: const CallableOptions(cors: Cors(['*'])),
      (request, response) async {
        if (request.auth == null) {
          throw FirebaseFunctionsException(
            code: 'unauthenticated',
            message: 'You must be signed in to create a post.',
          );
        }

        final uid = request.auth!.uid;
        final data = request.data as Map<String, dynamic>? ?? {};

        final title = data['title'] as String?;
        final content = data['content'] as String?;

        final titleError = PostValidation.validateTitle(title);
        if (titleError != null) {
          throw FirebaseFunctionsException(
            code: 'invalid-argument',
            message: titleError,
          );
        }

        final contentError = PostValidation.validateContent(content);
        if (contentError != null) {
          throw FirebaseFunctionsException(
            code: 'invalid-argument',
            message: contentError,
          );
        }

        try {
          final ref = await firebase.adminApp
              .firestore()
              .collection(ApiConstants.postsCollection)
              .add({
            'title': title!.trim(),
            'content': content!.trim(),
            'authorId': uid,
            'likeCount': 0,
            'createdAt': FieldValue.serverTimestamp(),
          });

          return CallableResult({
            'postId': ref.id,
            'success': true,
          });
        } catch (e) {
          print('Error writing post to Firestore: $e');
          throw FirebaseFunctionsException(
            code: 'internal',
            message: 'Failed to create post. Please try again.',
          );
        }
      },
    );
  });
}

final data = request.data as Map<String, dynamic>? ?? {} safely handles the case where the client sends a null body by falling back to an empty map, preventing a null dereference before the individual field extractions. The ! on title!.trim() and content!.trim() is safe at this point in the code because the validation checks above have already confirmed that both values are non-null and non-empty. The try/catch around the Firestore write is the final safety net: if the Admin SDK write fails for any reason (network issue, Firestore quota, unexpected error), the function catches it, logs the full internal error with print (which writes to Cloud Run logs), and throws a sanitized 'internal' error to the client that says nothing about the cause of the failure.

The Flutter App

// lib/services/functions_service.dart

import 'package:cloud_functions/cloud_functions.dart';

class FunctionsService {
  static const String _createPostUrl =
      'https://createpost-REPLACE-WITH-YOUR-HASH.a.run.app';

  Future<String> createPost({
    required String title,
    required String content,
  }) async {
    try {
      final callable = FirebaseFunctions.instance
          .httpsCallableFromURL(_createPostUrl);

      final result = await callable.call({'title': title, 'content': content});

      return result.data['postId'] as String;
    } on FirebaseFunctionsException catch (e) {
      throw _mapError(e);
    }
  }

  Exception _mapError(FirebaseFunctionsException e) {
    switch (e.code) {
      case 'unauthenticated':
        return Exception('Please sign in to continue.');
      case 'invalid-argument':
        return Exception(e.message ?? 'Invalid input.');
      default:
        return Exception('Something went wrong. Please try again.');
    }
  }
}

FunctionsService is a thin wrapper around the callable function invocation. Its only responsibilities are constructing the callable with the correct URL, passing the data, extracting the result, and mapping structured server errors into domain exceptions. _mapError translates FirebaseFunctionsException objects, which carry Firebase-specific codes, into plain Exception objects with user-friendly messages. This keeps Firebase types out of the Bloc or widget layer, where they would create a coupling to the Firebase SDK that is difficult to test or replace.

// lib/features/create_post/create_post_screen.dart

import 'package:flutter/material.dart';
import 'package:shared/shared.dart';
import '../../services/functions_service.dart';

class CreatePostScreen extends StatefulWidget {
  const CreatePostScreen({super.key});

  @override
  State<CreatePostScreen> createState() => _CreatePostScreenState();
}

class _CreatePostScreenState extends State<CreatePostScreen> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _contentController = TextEditingController();
  final _service = FunctionsService();

  bool _isSubmitting = false;
  String? _errorMessage;

  @override
  void dispose() {
    _titleController.dispose();
    _contentController.dispose();
    super.dispose();
  }

  Future<void> _submit() async {
    if (!(_formKey.currentState?.validate() ?? false)) return;

    setState(() {
      _isSubmitting = true;
      _errorMessage = null;
    });

    try {
      final postId = await _service.createPost(
        title: _titleController.text,
        content: _contentController.text,
      );

      if (!mounted) return;

      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Post created successfully! ID: $postId')),
      );

      Navigator.of(context).pop();
    } catch (e) {
      setState(() => _errorMessage = e.toString());
    } finally {
      if (mounted) setState(() => _isSubmitting = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('New Post')),
      body: Form(
        key: _formKey,
        child: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            if (_errorMessage != null)
              Container(
                padding: const EdgeInsets.all(12),
                margin: const EdgeInsets.only(bottom: 16),
                decoration: BoxDecoration(
                  color: Colors.red.shade50,
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Text(
                  _errorMessage!,
                  style: TextStyle(color: Colors.red.shade800),
                ),
              ),
            TextFormField(
              controller: _titleController,
              decoration: InputDecoration(
                labelText: 'Title',
                hintText: 'What is your post about?',
                counterText:
                    '\({_titleController.text.length}/\){PostValidation.titleMaxLength}',
              ),
              maxLength: PostValidation.titleMaxLength,
              validator: (value) => PostValidation.validateTitle(value),
              onChanged: (_) => setState(() {}),
            ),
            const SizedBox(height: 16),
            TextFormField(
              controller: _contentController,
              decoration: InputDecoration(
                labelText: 'Content',
                hintText: 'Write your post here...',
                counterText:
                    '\({_contentController.text.length}/\){PostValidation.contentMaxLength}',
                alignLabelWithHint: true,
              ),
              maxLength: PostValidation.contentMaxLength,
              maxLines: 10,
              validator: (value) => PostValidation.validateContent(value),
              onChanged: (_) => setState(() {}),
            ),
            const SizedBox(height: 24),
            FilledButton(
              onPressed: _isSubmitting ? null : _submit,
              child: _isSubmitting
                  ? const SizedBox(
                      height: 20,
                      width: 20,
                      child: CircularProgressIndicator(
                        strokeWidth: 2,
                        color: Colors.white,
                      ),
                    )
                  : const Text('Publish Post'),
            ),
          ],
        ),
      ),
    );
  }
}

GlobalKey<FormState> gives _submit() access to the form's state so it can trigger validation across all fields simultaneously. _formKey.currentState?.validate() calls the validator function on every TextFormField in the form and returns true only if all validators return null. The early return on validation failure prevents the network call from being made when the form is invalid. _isSubmitting drives the UI state: the button is disabled (onPressed: null) while the call is in progress, and a CircularProgressIndicator replaces the button label, giving the user clear feedback that something is happening. if (!mounted) return inside the async _submit() method prevents calling setState or Navigator on a widget that has already been removed from the tree, which would throw a "setState called after dispose" error. The finally block ensures _isSubmitting is always reset to false, even if an exception was thrown, preventing the button from being permanently stuck in the loading state.

// lib/main.dart

import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_functions/cloud_functions.dart';
import 'dart:io' show Platform;
import 'firebase_options.dart';
import 'features/create_post/create_post_screen.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  if (const bool.fromEnvironment('USE_EMULATOR', defaultValue: false)) {
    final host = Platform.isAndroid ? '10.0.2.2' : 'localhost';
    FirebaseFunctions.instance.useFunctionsEmulator(host, 5001);
  }

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Full-Stack Dart Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
        useMaterial3: true,
      ),
      home: const CreatePostScreen(),
    );
  }
}

WidgetsFlutterBinding.ensureInitialized() must be called before any Flutter plugin code runs, which includes Firebase initialization. Without it, calling Firebase.initializeApp() before runApp() would throw an error. DefaultFirebaseOptions.currentPlatform reads from the generated firebase_options.dart file to get the correct Firebase project configuration for the current platform. const bool.fromEnvironment('USE_EMULATOR', defaultValue: false) reads a compile-time constant that you can set by passing --dart-define=USE_EMULATOR=true to your flutter run command. This approach to emulator switching is safer than using kDebugMode, because a release build with kDebugMode set to false would stop using the emulator, whereas a release build compiled without --dart-define=USE_EMULATOR=true achieves the same result explicitly. Platform.isAndroid selects the correct emulator host address for the current platform, as discussed in the setup section.

Conclusion

Dart on Cloud Functions is the feature the Flutter community has wanted for years, and the announcement at Google Cloud Next 2026 was met with the kind of enthusiasm that only comes when a long-standing pain point is finally addressed. The user voice thread that had been accumulating requests since 2023 filled with celebration. Developers who had learned just enough TypeScript to write backend functions and had never been comfortable with it suddenly had a path back to the language they know.

The technical foundations are genuinely strong. Dart's AOT compilation produces lower cold start times than interpreted runtimes. Its null-safe, strongly typed system makes the shared package pattern reliable rather than aspirational. Its async model handles I/O-bound serverless workloads efficiently. The firebase_functions package mirrors the ergonomics of the FlutterFire packages Flutter developers already use, so the learning curve is shallow for anyone who has already integrated Firebase on the client.

The experimental status is real and must be respected. Background triggers are not yet deployable. The Firebase Console does not display Dart functions. Name-based callable invocation does not work. These are not paper-thin limitations: they affect real architecture decisions, and teams should design around them explicitly rather than assuming they will be resolved before their launch date. The Firebase team is actively developing the feature, and the pace of progress since the announcement has been encouraging, but production systems deserve conservative planning.

The shared package is the idea worth centering your architecture around, regardless of how mature the Dart functions feature becomes. Even if you keep some backend logic in Node.js for now because of the trigger limitations, building your shared data models and validation logic in a common Dart package that both sides import is an immediate improvement to your codebase. Every time you eliminate a duplicated type definition or a manually maintained API contract, you remove a category of bugs that no amount of testing fully eliminates. The package is the payoff that is available today, and the Dart functions feature is the amplifier that makes the whole unified stack possible.

The Flutter community is just beginning to explore what full-stack Dart looks like at scale. The patterns for organizing shared packages, structuring functions for testability, managing the tradeoffs between callable and HTTP functions, and handling the current limitations gracefully are still being established in real projects. This handbook gives you the foundations. The community will fill in the rest as more teams ship production workloads and share what they learn.

References

Official Firebase Documentation

Announcement and Blog Posts

Packages

Codelabs and Tutorials

This handbook was written in May 2026, reflecting the experimental Dart Cloud Functions support announced at Google Cloud Next 2026, the firebase_functions package at version 0.1.x, and the dart_firebase_admin package maintained by Invertase. Because this feature is experimental, the API and supported trigger types may change in future releases. Always consult the official Firebase documentation and the package changelogs before upgrading.



Read the whole story
alvinashcraft
just a second ago
reply
Pennsylvania, USA
Share this story
Delete

Azure Service Bus: Earn the redesign

1 Share

TL;DR: Micro-optimizations are not a substitute for design work. They are how you earn the right to redesign. In the Azure Service Bus SDK, repeated work in the Body property first led to smaller allocation fixes. Once those fixes exposed the shape of the problem, a small internal redesign made the code faster, clearer, and easier to reason about.

“This code is bad. We should rewrite it.”

Most developers have heard that sentence. Many have said it. I have too. The problem is not that rewrites are always wrong. The problem is that a rewrite without measurements is often just the same misunderstanding with newer syntax.

Performance work gives you a better path. Profile the code. Improve one thing. Benchmark it. Profile again. Repeat until the shape of the problem stops being mysterious. Sometimes that loop ends with a tiny change. Sometimes it teaches you enough to make a redesign safe.

The earlier Event Hubs posts in this series looked at that loop from the micro-optimization side: first removing temporary allocations from partition-key encoding, then tightening the Jenkins lookup3 hash loop. This story looks at the point where the same loop starts pointing past another small tweak and toward a better internal design.

The harmless property that was not harmless

The Azure Service Bus client exposes message payloads through a Body property. That is the kind of API developers expect to be cheap. Properties feel like fields. A caller might read message.Body once to deserialize JSON, again for logging, and once more while debugging. Nothing about the shape of the API suggests that each access might rebuild the body.

But under the covers, the client also has to deal with Advanced Message Queuing Protocol (AMQP) message bodies. Those bodies can be represented as one data section, multiple data sections, or lower-level structures exposed through raw AMQP APIs. Turning that into BinaryData, the Azure SDK type used to represent binary payloads, is not just returning a field. It can involve combining memory segments and copying bytes.

The first pull request in this sequence tried the direct fix: cache the computed BinaryData so the same body was not rebuilt over and over. The review discussion quickly found the catch. The raw AMQP message is mutable. AMQP is the wire protocol underneath Service Bus, with its own message representation that advanced callers can reach into directly. If a caller gets the raw message and changes the body, a cached Body value can become stale.

That is where the performance problem became more interesting. The problem was not just “there is an allocation.” The problem was that the design did not clearly express ownership, mutation, or when bytes had to be copied.

The first fixes taught us where the boundaries were

The next version introduced an internal body wrapper so the SDK could avoid recomputing the same data in the common case. For most users, the raw AMQP message is never touched. They create a ServiceBusMessage, set the body, send it, receive it, and read the Body property. Optimizing that path matters because it is the path most applications use.

At the same time, the SDK still had to preserve the advanced path. If someone reaches into the raw AMQP message and mutates the body, the code cannot pretend that nothing happened. That case may be uncommon, but it is still part of the contract.

The PR discussion separated the cases into two different lifetimes. On the send path, the body often starts as caller-owned ReadOnlyMemory<byte>. If it is a single segment, the SDK does not need to copy it immediately. On the receive path, the SDK receives buffers from the AMQP library, and those buffers need to be copied before the underlying message can be released.

That distinction was the design insight. The code did not need one generic “body memory” trick. It needed names for the different body behaviors.

The redesign made the intent visible

The follow-up PR split the internal body handling into distinct implementations. The names matter here. They tell future readers why each path exists.

internal abstract class MessageBody : IEnumerable<ReadOnlyMemory<byte>>
{
    public static MessageBody FromReadOnlyMemorySegment(ReadOnlyMemory<byte> segment)
    {
        return new NonCopyingSingleSegmentMessageBody(segment);
    }

    public static MessageBody FromReadOnlyMemorySegments(IEnumerable<ReadOnlyMemory<byte>> segments)
    {
        return segments is MessageBody messageBody
            ? messageBody
            : new CopyingOnConversionMessageBody(segments);
    }

    public static MessageBody FromDataSegments(IEnumerable<Data> segments)
    {
        return new EagerCopyingMessageBody(segments);
    }
}

This sample is simplified from the production code, but it captures the important shift. The implementation no longer hides three behaviors behind one vague helper. It says what the code is doing:

The segments is MessageBody check is a small fast path. If the data is already one of the SDK’s internal body wrappers, the factory returns it instead of wrapping it again. That keeps repeated conversions from adding another layer of indirection.

  • NonCopyingSingleSegmentMessageBody wraps the common send path without copying.
  • CopyingOnConversionMessageBody delays copying until a flattened body is needed.
  • EagerCopyingMessageBody copies receive buffers immediately because the source lifetime does not belong to the SDK.

That is not just faster code. It is more honest code. A reviewer can read the type name and understand the trade-off before opening the method body.

The awkward allocation was a design smell

The original symptom was allocation on property access. The deeper problem was that callers and internal code were crossing representation boundaries too often. A public Body property wants one continuous BinaryData value. The AMQP data body may be multiple sections. The send path and receive path have different ownership rules. The old implementation paid conversion costs because those differences were not modeled explicitly.

Once the code had separate internal body types, the rules moved into one place. A single segment could stay a single segment. Multiple segments could remain separate until flattened. Receive buffers could be copied at the moment where copying was required for safety.

The pull request review also improved the internal names. What started as BodyMemory became Body, and later MessageBody. That kind of rename looks small in a diff, but it matters. The name changed from describing a storage detail to describing the domain concept inside the SDK.

Performance work often starts with bytes and ends with language. Once you understand the code, you can name the concepts that were missing.

The next wall was inside the copy itself

A year later, another pass looked at the receive-side eager copy path again. The redesign had made the path clear enough to optimize further. This time the issue was not whether to copy. The receive path had to copy. The question was whether the copy code was doing extra work while building the destination buffer.

The later PR changed the copy helper to first walk the segments, calculate the total length, and create the destination buffer with enough capacity up front. That avoided repeated buffer growth while appending segment after segment.

int length = 0;
List<ReadOnlyMemory<byte>> segments = new();

foreach (Data segment in dataSegments)
{
    ReadOnlyMemory<byte> data = GetData(segment);
    length += data.Length;
    segments.Add(data);
}

ArrayBufferWriter<byte> writer = new(length);

Again, the sample is simplified. The production code then makes a second pass and copies each segment into the pre-sized writer. The point is the pattern: when a copy is unavoidable, make it one intentional copy into a correctly sized destination. Avoid making the buffer discover its final size by growing repeatedly.

Do not apply that pattern blindly to unbounded data. The first pass keeps segment references so the second pass can copy them, and the total length is accumulated in an int. That is reasonable for Service Bus message bodies, where message size is bounded by the service, but general-purpose code should still think about maximum size and overflow behavior.

StepWhat changedWhat the team learned
Cache attemptAvoid rebuilding Body on repeated property accessThe raw AMQP message can be mutable, so caching has correctness boundaries
Internal body abstractionRepresent send, receive, and conversion paths explicitlyThe performance problem was tied to ownership and lifetime
Split implementationsUse non-copying, copy-on-conversion, and eager-copying pathsClearer design made the common path cheaper and the uncommon path safer
Copy helper optimizationPre-size the buffer before copying segmentsOnce the design was clear, smaller optimizations became easier to target

Why not redesign first?

Because the early attempts were not wasted. They produced the knowledge needed for the redesign. The first PR showed that repeated property access was expensive. The review showed that mutability prevented a naive cache. The next PRs separated the send and receive assumptions. Later benchmarks showed that the eager-copy implementation still had room to improve.

If someone had started with “let’s rewrite body handling,” the discussion would have been abstract. Instead, each small change exposed a constraint. By the time the internal design changed, the team had concrete examples, profiler snapshots, tests, and review comments to guide it.

That is the part I wish more teams wrote down in pull requests and architecture decision records. Not just the final design, but the path that made the design obvious. Future maintainers need to know why the code has three body implementations. Without that context, they may collapse it back into one helper and reintroduce the same cost.

What I took away

Do the boring performance loop before reaching for a rewrite. Profile, improve, benchmark, and repeat. Use stable machines where you can. Look at production telemetry after shipping, because benchmarks can prove that a change helps a scenario, but production tells you whether that scenario mattered.

Micro-optimizations are not the opposite of design. They are a way to learn the design pressure in real code. In the Event Hubs partition-key resolver, that meant making a small hot path do less work while keeping the same design. In the Service Bus body path, the same loop showed that the design needed sharper boundaries: non-copying when ownership is safe, copying on conversion when flattening is requested, and eager copying when the receive path requires it.

That is the kind of redesign I trust: measured, incremental, and informed by the code that came before it.

Further reading:

Common questions

This section answers the questions I would ask before applying the same idea to application code.

Should I redesign code as soon as profiling finds allocations?

No. Try to understand the allocation first. A small change may be enough, and even when it is not, the small change teaches you which constraints the redesign must respect.

Is a property allowed to do expensive work?

Sometimes it has to, but callers usually assume properties are cheap. If a property sits on a hot path, repeated allocation or conversion inside the getter deserves extra scrutiny.

What is the main lesson?

Optimize first to learn. Redesign only when the measurements and constraints show that the old shape is the problem.

Read the whole story
alvinashcraft
20 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

What Happens When You Give AI Agents the Map of Your Code’s Coverage?

1 Share

When you ask an AI agent to write a new feature, a good agent will eventually say: “I need to write a test for this.”

But what happens next is usually messy.

To figure out where that new test belongs, the agent has to start searching through your project. It might scan file names, inspect folders, grep for method names, and read file after file just to understand how your tests are organized. That burns through your token limits quickly.

Even worse, in complex .NET solutions where a single production file may be tested across multiple projects or test suites, the agent can still guess wrong. It might put the test in the wrong place, miss the relevant test fixture, or follow a completely different testing style from the one your team already uses.

In the Rider 2026.2 EAP, we’re improving this workflow by teaching AI agents a new skill – one that leverages JetBrains tooling for .NET coverage data to slash your AI expenses in half. 

Why AI agents need more than just code context

AI agents are useful because they can work through multi-step development tasks. They can inspect code, make changes, run tests, react to failures, and iterate.

But even capable agents are only as good as the context they receive.

When an agent needs to add a test, the question is not only: “What should this test assert?” It also needs to know:

  • Where do tests for this code usually live?
  • Which existing tests already exercise nearby code?
  • What testing framework, fixture structure, naming convention, and assertion style does this project use?
  • Is there already a test file that should be extended instead of creating a new one?

A human developer often knows this from experience. An AI agent usually has to discover it the expensive way: by reading the entire project.

That’s where AI agent skills come in.

What are agent skills?

If you’ve been following developments in AI-assisted development, you may have come across the concept of Agent Skills – an open standard introduced by Anthropic to extend AI agent capabilities with specialized knowledge and workflows. You can learn more about agent skills in JetBrains IDEs from this blog post.

In Rider, skills give AI agents access to IDE-native context and workflows. Instead of relying only on generic documentation or asking the model to explore your project manually, a skill can help the agent perform a specific task with information Rider already understands.

You can manage skills in Rider from Settings / Preferences | Tools | AI Assistant | Skills. Skills can be enabled in different scopes depending on how and where you want to use them:

  • IDE scope: Available for all projects and all agents inside the Rider UI. 
  • Global per-agent scope: Available for a specific agent across all projects, including outside the IDE. For example, this can be configured in an agent-specific directory such as ~/.codex.
  • Per-project scope: Available for all agents working with a specific project, including outside the IDE. For example, this can be configured in a project-level .agents directory.
  • Per-project per-agent scope: Available only for a specific agent in a specific project, such as a project-level .codex directory.

Once installed, skills can be used by supported agents automatically when they are relevant to the task. You can also invoke a skill manually in the AI chat by typing / followed by the skill name.

Enter the finding-tests skill

While ecosystems like Microsoft’s dotnet/skills give AI agents generic documentation on how to write .NET tests, they still leave the AI guessing where to put them.

Instead of letting the agent aimlessly search your codebase whenever it needs to write a test, we’ve introduced a new agent skill called finding-tests. It ships in two parts: as a bundled skill for Rider’s AI Assistant, and as a standalone MCP tool (findTests) for use with external agents like Claude Code or Codex.

The bundled skill is enabled by default in Rider’s AI Assistant in the Rider 2026.2 EAP. To use it with an external agent, install the skill in the relevant agent or project scope, then make sure the external client can access Rider via MCP. You can do this from Settings / Preferences | Tools | MCP Server: enable the MCP server, then use Auto-Configure to set up access for the external client.

The idea is simple: Rider already has access to powerful .NET coverage analysis through the bundled dotCover tool,  so when an AI agent needs to understand where a piece of code is tested, it should not have to infer that relationship solely from folder names and search results.

It can just ask Rider.

Rider will then use dotCover coverage data to identify which tests are connected to the code the agent is working on. That turns coverage data into actionable context for AI-generated tests.

Here is what the workflow looks like under the hood:

  1. The agent decides it needs a test. When the AI writes new code, modifies existing behavior, or you explicitly ask it to add test coverage, it can trigger the finding-tests skill.
  2. Rider asks dotCover for coverage context. dotCover runs the tests included in the solution and maps out the coverage data around the code the agent is working with.
  3. The right test location is found. Because Rider can understand which tests already cover nearby code, it can provide the agent with the relevant test file path instead of making the agent discover it manually.
  4. The agent follows your existing test style. The agent goes directly to the correct file, reads the surrounding tests, follows your project’s conventions, and generates a test that fits the existing codebase.

And here’s what the full workflow looks like inside the IDE:

The result: Token costs are halved

This isn’t just a quality-of-life improvement. It has a major impact on your workflow – and your budget.

✂️ Token costs are cut by 50%.

By stopping the AI from wandering needlessly through your project, you’d be saving drastic amounts of money.

In our internal benchmarks (primarily testing with the Claude agent), routing the agent directly to the correct file could cut token consumption by up to 50%. 

Cost comparison across a range of C# test generation cases in real open-source solutions shows that using the finding-tests agent skill with Claude can significantly reduce average AI agent task costs.

The benefit is not just lower AI spend. For teams working with quota-based access or shared AI credit pools, it also means fewer credits wasted on search and exploration. Instead, more of your AI allowance can go toward higher-value development tasks. 

🎯 Correct file, every time

Without coverage data, the agent guesses. In a large codebase where one class might be tested from multiple locations, those guesses are often wrong, resulting in tests dropped in the wrong file, written in a mismatched style, or targeting the wrong test suite entirely.

With finding-tests, the agent has a precise map. No guessing. No wrong file. No style mismatch.

Time vs. tokens

We want to be completely transparent about how this works: this setup trades token expenditure for time. 

To give the AI the exact file path, dotCover has to run a coverage analysis on your solution. For a small or medium project, this might take 30 seconds. But if you are working in a massive codebase, running a full coverage scan could take minutes or even hours.

If you have a release deadline tomorrow, the last thing you want is your IDE suddenly initiating a multi-hour test run. Luckily, the solution is fairly simple.

How to turn it off

Because of this time trade-off, putting you in control is our top priority. The finding-tests skill is bundled and enabled by default in this EAP, but you can disable it or keep it limited to specific projects.

To manage it, go to Settings / Preferences | Tools | AI Assistant | Skills. Find the finding-tests on the panel, and you can inspect the skill, disable it, or use the dropdown to configure it for specific projects.

You can easily re-enable the skill later. 

Known issues

As this is an Early Access Program (EAP), we’re still ironing out a few wrinkles. Here is what you should look out for:

  • First-run hiccups: The finding-tests tool occasionally stumbles or fails on its very first launch. A second attempt usually ensures it is on track.
  • Codex support in the IDE is currently limited: At the moment, bundled skills are not available for Codex inside AI Assistant because of a known issue with the skill bundling mechanism. The AI Assistant team is working on a fix.

Possible workaround: If you want to use finding-tests with Codex, install the skill explicitly in either the global or project scope. This makes the skill available to both external and in-IDE agents.

  • Timeouts on large solutions: Codex and Copilot agents currently time out if a full test run takes longer than 120 seconds. We know real-world solutions can take longer to test, and we’re working on optimizing this pipeline.

    Workaround for Codex: External Codex can mitigate this by increasing the tool timeout in the MCP configuration. Set a larger value for the tool_timeout_sec parameter in the global or project MCP config as per Codex documentation.
  • External agents require MCP setup: To use finding-tests with external agents such as Claude Code or Codex, you’ll need to enable Rider’s MCP server and configure MCP access for the agent.
    Go to Settings / Preferences | Tools | MCP Server, enable the MCP server, and then either auto-configure or manually configure Rider’s MCP server for your agent. After that, install the skill in the agent’s global or project scope.

What’s next on the roadmap

Here’s a sneak peek at where this is heading: if the finding-tests skill proves valuable, our next step will be introducing target coverage – a feature where the AI agent automatically generates enough unit tests to hit a specific, pre-selected percentage of code coverage.

This would let you easily meet mandatory coverage requirements without having to spend extra time manually writing tests. Your feedback on this EAP directly influences whether this feature gets built.

Tell us what you think

This feature is available to all users in the Rider 2026.2 EAP, which gives you a good opportunity to also explore dotCover – our star code coverage tool that normally requires a dotUltimate license – for free. 

For now, we need to know whether this skill works and provides enough benefits for you, and whether there are any edge cases we need to take into account. Try out the new test generation, see how it impacts your workflow, and please let us know whether this skill should remain bundled by default or moved to an optional registry!

Read the whole story
alvinashcraft
30 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Rider 2026.2 EAP 3: Cost-effective Agentic Test Coverage, Code Change Previews, GameDev Templates, and NuGet Improvements

1 Share

JetBrains Rider 2026.2 EAP 3 is out!

You can download this version from our website, update directly from within the IDE, use the free Toolbox App, or install it via snap packages.

Here’s what you can expect from this update:

New AI agent skill to reduce token use for test generation

We’re also experimenting with an AI agent skill for unit test generation that uses Rider’s built-in coverage data to produce more relevant tests. When you ask your AI agent to generate tests, Rider can use dotCover coverage insights to find existing related tests, follow your project’s testing style, and generate the perfect tests with no manual guidance or costly wandering round the codebase. In our internal benchmarks, this approach reduced token consumption by up to 50%. You’ll find more details in this blog post.

The ability to preview suggested code changes

Rider now gives you a clearer way to evaluate quick-fixes and context actions before applying them. The new intention previews show what the selected action will change directly from the actions menu, helping you understand the result at a glance and choose the right fix with more confidence.

The preview supports diff-based output with syntax and identifier highlighting, so you can quickly compare the before and after states without interrupting your flow. This is especially helpful for broader changes, including fixes that affect multiple files, where seeing the exact impact upfront makes code actions feel safer and easier to trust.

Game development project templates

Rider now includes a dedicated Game Development section in the New Project dialog, making it easier to get started without any overcomplicated manual setup.

Godot is the first pilot for this updated experience. You can create either a game extension or an editor extension, with options to include C++ GDExtension support and the CMake add-on manager (the JetBrains Rider add-on is preconfigured where relevant). It’s a faster path to a working project, especially if you’re new to Godot development in Rider.

This release also lays the groundwork for more game-specific templates in Rider. Alongside the Godot pilot, we’re introducing a CMake game project template and reorganizing the New Project experience so game development templates have a clearer, dedicated entry point.

If you would like to learn how to use the new templates and develop Godot addons, check our documentation.

Improved experience in the NuGet tool window

Managing dependencies can get noisy as a solution grows. You need to find new packages, keep existing dependencies up to date, and quickly understand which projects are affected by available updates – ideally without digging through the same package list over and over again.

We’ve redesigned the NuGet tool window in Rider to make that workflow easier to understand and act on. The updated experience separates browsing for packages from managing installed dependencies, so each task has its own clearer path.

Available updates now also have a dedicated place in the tool window, making it easier to see which packages need attention and update them when you’re ready. This should make routine dependency maintenance more focused, especially in larger solutions with multiple projects and many installed packages.

Optimized garbage collection in Rider’s backend

We’ve adjusted several garbage collection settings to help the Rider backend release unused memory more efficiently.

Based on our internal tests, these changes reduced memory usage for Rider backend processes by around 7–8% on average. Your results may vary depending on your project and environment, but Rider should now be better at managing backend memory during everyday development.

For the full list of improvements and fixes included in this build, please see our release notes.

That’s it for now! As always, we’d love to hear your thoughts in the comments below.

Read the whole story
alvinashcraft
36 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Announcing NAudio 3 Preview

1 Share

I'm pleased to announce that the first preview builds of NAudio 3 are available on NuGet. I have put a lot of time into this over the past few months, and with the assistance of Claude Code (which I wrote about recently) I've managed to make significant progress.

In this post I want to cover the most important things you need to know about what has changed.

Modernization

The key theme has been to modernize the codebase, including a major rewrite of the interop to use improved techniques such as [GeneratedComInterface] and [LibraryImport]. This meant that we have finally dropped support for the legacy .NET Framework, as well as retire the dedicated UWP assembly. NAudio 3 will require .NET 9 or newer. If you need to run on older versions of .NET you will need to stay on NAudio 2.

Span<T>

Another big change is making Span<T> the primary way that audio is passed around, instead of the previous byte[] buffer, int offset, int count triple that had to be passed everywhere and led to bugs whenever someone forgot that offset might be non-zero. Using Span<T> reduces the amount of copying that is needed and allows us to read directly from unmanaged memory if needed. The main place you'll see this is that IWaveProvider and ISampleProvider now use Span<byte> and Span<float> respectively. Although this is a breaking change, it's one that I'm hoping won't be too disruptive as most NAudio users simply make use of the built-in derived types.

New WasapiPlayer and WasapiRecorder

The recommended approach for audio playback and recording on Windows is through the WASAPI APIs, and although I'm leaving the legacy WasapiOut, WasapiCapture and WasapiLoopbackCapture in place, I wanted to start afresh so there is now WasapiPlayer and WasapiRecorder which replace them, and use a builder pattern. Check out the tutorials WasapiPlayer and WasapiRecorder to see them in action. I'd recommend moving across to them for new development - we'll probably keep the old classes in place for one more major version to ease the transition.

New AsioDevice

Similarly with ASIO, the old AsioOut class which had an ugly InitRecordAndPlayback for duplex playback and recording is essentially retired in favour of a new AsioDevice which aims to simplify the three most common use cases - playback only, record only, and duplex mode - each has a dedicated method (InitPlayback, InitRecording and InitDuplex). Check out the tutorials AsioPlayback, AsioRecording, and AsioDuplex for examples of these in action. I'm particularly pleased that NAudio is now in a much better place to support low-latency audio - which was always a dream of mine since I started the project.

Legacy WinMM and DMO

The WinMM and DMO parts of NAudio are still present, but considered "legacy" now, and hosted in their own Windows-only NAudio.WinMM and NAudio.Dmo assemblies. I did some renaming. The oddly named WaveInEvent and WaveOutEvent which became the recommended recording and playback options for WinMM are now renamed to WaveIn and WaveOut. The old WaveIn and WaveOut are still present but renamed to WaveInWindow and WaveOutWindow and live in the NAudio.WinForms assembly as they depend on window handles for their message callbacks. DirectSoundOut has been rehoused into NAudio.Dmo and should also be considered legacy.

Linux Support with ALSA and libsndfile

One very exciting announcement is that NAudio finally supports playback and recording on Linux thanks to a community contribution that I reworked with the help of Claude into the style of NAudio 3. Use NAudio.Alsa for audio playback and recording on Linux. And on top of that NAudio.SoundFile wraps libsndfile which itself is a cross-platform library that can read and write a wide variety of audio file types. This not only brings support for these file types to Linux, but also to Windows and macOS. I have limited ability to test on Linux, so this part of NAudio should definitely be considered "preview". I'd love to hear if you have any success using this.

WinRT-based MIDI

There are also new WinRTMidiIn and WinRTMidiOut that provide MIDI in and out using the more modern WinRT APIs (although these soon may be superseded by a newer MIDI API Microsoft are working on). For the time being these classes live in NAudio.Wasapi.

Improved test harnesses

With so many big changes, I needed to ensure I had a quick way to check things weren't broken. The WinForms (NAudioDemo) and WPF (NAudioWPFDemo) projects both have had significant improvements to give me an easy way to drive many key NAudio features. And there is now also NAudioConsoleTest, which is a terminal-based testing tool that supports scripting as well as menu-based navigation of the various test scenarios.

Bugfixes

I've also spent a huge amount of time trying to revisit as many open issues and PRs as possible. There were almost 500 open issues and I've responded to over 150 of them now. Many of these have resulted in bug fixes and enhancements. I've still got a very long way to go though, so thank you for your patience and apologies to everyone who never got a reply to their question.

What's Coming Next?

Although I don't want to rush NAudio 3 out before it's ready, at the same time I don't want to delay it too much by implementing every idea I have. One very exciting thing in the works is a library of effects (think reverb, delay, compressor, etc.). I have a great demo where you can run them in real-time using ASIO - great for setting up a guitar effects chain. That's not too far from being ready (although again the first drop of effects should be considered "preview" quality).

Another very ambitious idea I have is to implement VST3 hosting. I've made very good progress on this, although there is a long way to go still, so that might not make it in. Even more stretch goals I have are for NAudio to ship with a basic synthesizer, sampler and sequencer - finally implementing three of the things I most wanted to create with NAudio when I first started it - but each of those is a major undertaking in its own right.

I'm not sure if the public API is going to change much more. One idea I had was to replace WaveFormat with something less tied to the Windows WAVEFORMATEX structure and so experimented for a while with an AudioFormat base class. However, the change ended up being quite disruptive so that might be something to revisit for NAudio 4 to give me more time to properly think through what the best design would be.

Similarly, I've wanted to address the challenges of dealing with both repositionable WaveStream and non-repositionable IWaveProvider/ISampleProvider instances. There are a few ideas I have to improve things, and one option is to use the new audio effects framework as a solution - allowing people to insert a chain of audio effects into a WaveStream which could give the best of both worlds. This will need some more thought though as it's a potentially disruptive change so I want to get it right.

One thing that's not on the agenda at this time is macOS audio playback as I don't have a device to test on. If I ever get one, that would certainly be one of the first things I'd want to try on it!

Try it yourself

Anyway, I'd love for you to try the NAudio 3 preview libraries for yourself by downloading them on NuGet. In the NAudio repo there are short doc tutorials for many of the new features (e.g. ASIO duplex, Linux playback with ALSA, and cross-platform audio files with libsndfile).

Read the whole story
alvinashcraft
50 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Introducing Syncfusion Toolkit for Blazor: Free Open-Source Blazor Components

1 Share

Introducing Syncfusion Toolkit for Blazor: Free Open-Source Blazor Components

TL;DR: We’re excited to introduce the Syncfusion Toolkit for Blazor, a free, MIT-licensed collection of open-source Blazor UI components designed to help developers build modern web applications faster.

With the Syncfusion Toolkit for Blazor, you get:

  • Free and MIT-licensed Blazor UI components,
  • Open-source access on GitHub,
  • NuGet package distribution,
  • Support for Blazor Server and WebAssembly,
  • Essential components such as Charts, Buttons, Dialogs, File Upload, Calendar, DatePicker, TextBox, Forms, Spinner, and more.

Whether you are building dashboards, admin panels, forms, internal business tools, SaaS apps, or enterprise web apps, the Toolkit for Blazor helps reduce repetitive UI development so you can focus on delivering better application experiences faster.

What is Syncfusion Toolkit for Blazor?

The Syncfusion® Toolkit for Blazor is a free, MIT-licensed collection of open-source Blazor UI components designed to help developers build modern Blazor applications faster.

Hosted on GitHub and distributed through NuGet, the toolkit provides reusable, customizable, and production-friendly UI components that developers can easily integrate into their applications.

Instead of building common UI infrastructure from scratch, developers can use ready-to-integrate components for charts, forms, dialogs, uploads, date selection, input handling, and other common application scenarios.

The Toolkit for Blazor focuses on providing essential UI building blocks that help developers quickly create modern business applications with cleaner and more maintainable code.

Why we built Syncfusion Toolkit for Blazor

Syncfusion has long provided a comprehensive commercial suite of Blazor UI components for enterprise application development. With the Syncfusion Toolkit for Blazor, we are extending our support for the Blazor developer community by offering a free and open-source set of essential UI components.

Open source helps developers by providing:

  • Greater transparency,
  • More flexibility,
  • Easier customization,
  • Community-driven collaboration, and
  • Long-term control over application UI infrastructure.

Our goal is simple:

Help developers build modern Blazor applications faster using reusable, community-friendly UI components under the MIT license.

This release reflects our continued commitment to supporting the .NET and Blazor ecosystem through open-source contributions and developer-focused tooling.

What’s included in the first release?

The first release of the Syncfusion Toolkit for Blazor includes commonly used UI components for modern web and business applications.

Charts

Create interactive visualizations such as line, bar, column, area, scatter, bubble, and other chart types for dashboards, analytics, and reporting experiences.

Blazor Charts
Blazor Charts

Button and Button Group

Add customizable buttons and grouped actions for forms, toolbars, dialogs, and modern application interfaces.

Date and time components

Calendar

Provide interactive calendar-based date selection experiences.

DatePicker

Simplify date selection with user-friendly calendar input.

DateTime Picker

Combine date and time selection in a single control.

TimePicker

Enable precise and flexible time-selection workflows.

Blazor Date and Time components
Blazor Date and Time components

TextBox and TextArea

Capture single-line and multi-line user input for forms, comments, notes, search fields, and data-entry screens.

Input and toggle Components

Checkbox

Enable simple and intuitive selection-based interactions.

Radio Button

Allow users to choose a single option from grouped selections.

Numeric Textbox

Accept formatted numeric input with validation-friendly behavior.

Toggle Switch Button

Create modern on/off interactions for settings and state management.

Blazor Input and Toggle components
Blazor Input and Toggle components

File Upload

Add file-upload capabilities with drag-and-drop support, progress tracking, and flexible upload experiences.

Blazor File Upload component
Blazor File Upload component

Dialog

Create alerts, confirmations, modal popups, and interactive dialogs for modern application workflows.

Blazor Dialog component
Blazor Dialog component

Spinner

Display loading and processing states clearly during asynchronous operations.

Together, these free Blazor UI components help developers build:

  • Dashboards
  • Admin panels
  • Reporting systems
  • Data-entry forms
  • Internal business tools
  • SaaS applications
  • Enterprise web applications

with significantly less UI setup effort.

Getting started with Syncfusion Toolkit for Blazor

Let’s create a simple Blazor app and add your first component from the Toolkit for Blazor.

Step 1: Create a new Blazor Web app

Start by creating a new Blazor Web app project using the following command.

dotnet new blazor -n MyBlazorApp

cd MyBlazorApp

Step 2: Install the NuGet package

Install the Syncfusion Toolkit for Blazor package using the following command:

dotnet add package Syncfusion.Blazor.Toolkit

Step 3: Register Syncfusion services

In your Program.cs file, register the Syncfusion Blazor service.

using Syncfusion.Blazor;

var builder = WebApplication.CreateBuilder(args);

builder.Services
       .AddRazorComponents()
       .AddInteractiveWebAssemblyComponents();

builder.Services.AddSyncfusionBlazor();

var app = builder.Build();

// Remaining app configuration

Step 4: Import the required namespaces

Add the following namespaces to the _Imports.razor file.

@using Syncfusion.Blazor
@using Syncfusion.Blazor.Toolkit

Step 5: Add the stylesheet reference

Add the required CSS reference to your layout file.

<link href="_content/Syncfusion.Blazor.Toolkit/styles/fluent2.min.css" rel="stylesheet" />

Step 6: Add your first component

Now, you can use the Toolkit for Blazor components on your Blazor page.

@page "/"
@using Syncfusion.Blazor.Toolkit.Buttons

<h1>Welcome to Syncfusion Toolkit for Blazor!</h1>

<SfButton OnClick="@HandleButtonClick" CssClass="e-success">
    Click Me!
</SfButton>

<p>Button clicked: <strong>@clickCount</strong> times</p>

@code
{
    private int clickCount = 0;

    private void HandleButtonClick()
    {
        clickCount++;
    }
}

Step 7: Run the application

Launch your application using the following command:

dotnet run

And, open the application in your browser and see the Syncfusion Toolkit for Blazor components in action.

Refer to the following image.

Blazor application built using the Blazor Toolkit
Web application built using the Toolkit for Blazor

Built for modern Blazor applications

The Syncfusion Toolkit for Blazor is designed for modern Blazor development scenarios, including:

  • Blazor Web Apps,
  • Interactive Server rendering,
  • Interactive WebAssembly rendering,
  • Interactive Auto mode,
  • Standalone Blazor WebAssembly apps,
  • Modern .NET versions, including .NET 8, .NET 9, and future-ready .NET releases.

This gives developers the flexibility to use the toolkit across different Blazor hosting and rendering models.

Why open source matters for Blazor developers

Open-source UI components provide more than free access. They give developers flexibility, visibility, and long-term control over their application UI infrastructure.

With the Syncfusion Toolkit for Blazor, developers can:

  • Review the source code,
  • Understand component implementation details,
  • Customize behavior based on project needs,
  • Contribute improvements,
  • Use the components in personal, startup, commercial, and enterprise applications.

Because the toolkit is released under the MIT license, developers can confidently adopt it across a wide range of application scenarios without complex licensing restrictions.

Start building with Syncfusion Toolkit for Blazor

Thanks for reading! Open source continues to accelerate innovation and developer collaboration, and we’re bringing that same philosophy to Blazor development with the Syncfusion Toolkit for Blazor.

With a growing collection of free, MIT-licensed Blazor UI components, developers can build dashboards, forms, dialogs, admin interfaces, and modern business apps faster while maintaining full flexibility and visibility into the source code.

The toolkit is hosted on GitHub and supported with documentation, API references, and practical examples to help teams get started quickly.

Explore the Syncfusion Toolkit for Blazor, try the components in your projects, and start building modern Blazor apps faster today.

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories